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
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// ZXing core for QR code generation
implementation("com.google.zxing:core:3.5.3")
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
@@ -4,6 +4,8 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.sync.Mutex
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
@@ -17,18 +19,30 @@ class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList()
var fullName: String = ""
/** Active MIB sessions keyed by loginId (= MIB username). */
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList()
/** 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()
/** 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()
/** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList()
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")]
@@ -53,13 +67,38 @@ class BasedBankApp : Application() {
/** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
/** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")]
// ─── BML helpers ──────────────────────────────────────────────────────────
/**
* Returns the BML session for the given account.
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
*/
fun bmlSessionFor(account: MibAccount): BmlSession? {
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
}
/** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/**
* Returns any active BML session for the given loginId.
* Tries all profiles for that login; falls back to legacy loginId key.
*/
fun anyBmlSessionFor(loginId: String): BmlSession? {
val profiles = bmlProfilesMap[loginId]
if (!profiles.isNullOrEmpty()) {
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
}
return bmlSessions[loginId]
}
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
fun bmlFlowFor(loginId: String): BmlLoginFlow =
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
// ─── Fahipay helpers ──────────────────────────────────────────────────────
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
@@ -29,9 +29,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
private val APP_VERSION = "2.1.43.345"
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
@@ -58,9 +58,27 @@ class BmlLoginFlow {
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** Full login: returns a BmlSession and the account list. */
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
/** PKCE params — generated once per login and reused across all profile activations. */
private var codeVerifier: String = ""
private var codeChallenge: String = ""
private var deviceId: String = ""
/** Profiles returned by the last successful [login] call. */
var lastProfiles: List<BmlProfile> = emptyList()
private set
// ─── Login ────────────────────────────────────────────────────────────────
/**
* Performs web authentication (login + TOTP) and returns the list of available profiles.
* Call [activateProfile] for each profile to obtain an access token + accounts.
*/
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
deviceId = generateDeviceId()
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
client.newCall(
Request.Builder().url("$BASE_URL/web/login")
.header("User-Agent", WEB_USER_AGENT).build()
@@ -82,15 +100,14 @@ class BmlLoginFlow {
loginResp.close()
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
// Step 3: GET 2FA page (refreshes blaze_session)
// Step 3: GET 2FA page (refreshes session cookies)
client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa")
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute().close()
val xsrf2 = xsrfToken() ?: xsrf
// Step 4: POST OTP
// Step 4: POST TOTP
val otp = Totp.generate(otpSeed)
val twoFaBody = JSONObject().apply {
put("code", otp)
@@ -104,18 +121,141 @@ class BmlLoginFlow {
twoFaResp.close()
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
client.newCall(
// Step 5: GET /web/profile — returns list of profiles for this account
val profileResp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile")
.header("X-XSRF-TOKEN", xsrf2)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val 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()
).execute().close()
// Step 6: PKCE OAuth authorize → extract auth code
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
val deviceId = generateDeviceId()
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val body = JSONObject().apply {
put("code", code)
put("channel", channel)
}.toString().toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val respCode = resp.code
val location = resp.header("Location") ?: ""
resp.close()
return when {
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
respCode == 302 ->
throw Exception("Invalid OTP — please try again")
else ->
throw Exception("Business OTP verification failed (HTTP $respCode)")
}
}
// ─── OAuth + account fetch ────────────────────────────────────────────────
/**
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
* Returns a fresh [BmlSession] and the profile's accounts.
*/
private fun doOAuthAndFetchAccounts(
loginTag: String,
profileName: String,
profileId: String
): Pair<BmlSession, List<MibAccount>> {
val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize")
@@ -135,14 +275,12 @@ class BmlLoginFlow {
Request.Builder().url(authorizeUrl)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val location = authorizeResp.header("Location")
authorizeResp.close()
val location = authorizeResp.header("Location")
?: throw Exception("OAuth authorize did not redirect")
val authCode = Uri.parse(location).getQueryParameter("code")
?: throw Exception("No auth code in OAuth redirect")
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
?: throw Exception("OAuth authorize did not return auth code")
// Step 7: Exchange auth code for access token
val tokenBody = FormBody.Builder()
.add("Device-ID", deviceId)
.add("code", authCode)
@@ -161,22 +299,28 @@ class BmlLoginFlow {
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close()
val tokenObj = JSONObject(tokenJson)
val accessToken = tokenObj.optString("access_token")
val accessToken = JSONObject(tokenJson).optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session, "bml_$username")
val accounts = fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts)
}
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
// ─── API methods ─────────────────────────────────────────────────────────
fun fetchAccounts(
session: BmlSession,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseDashboard(json ?: return emptyList(), loginTag)
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
}
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
@@ -331,9 +475,6 @@ class BmlLoginFlow {
return parseContacts(json, loginId)
}
/**
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
*/
fun initiateTransfer(
session: BmlSession,
debitAccount: String,
@@ -370,9 +511,6 @@ class BmlLoginFlow {
}
}
/**
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
*/
fun confirmTransfer(
session: BmlSession,
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(
session: BmlSession,
accountId: String,
@@ -516,10 +628,6 @@ class BmlLoginFlow {
} catch (_: Exception) { Pair(emptyList(), 0) }
}
/**
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
* Returns combined outstanding authorizations + settled statement entries.
*/
fun fetchCardHistory(
session: BmlSession,
cardId: String,
@@ -546,7 +654,6 @@ class BmlLoginFlow {
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>()
// Outstanding authorizations
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
@@ -567,7 +674,6 @@ class BmlLoginFlow {
}
}
// Settled statement entries
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
@@ -590,14 +696,64 @@ class BmlLoginFlow {
} catch (_: Exception) { emptyList() }
}
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
// ─── Parsing ──────────────────────────────────────────────────────────────
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
/**
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
*/
private fun extractInertiaJson(html: String): String? {
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
return match.groupValues[1]
.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
private fun parseProfiles(html: String): List<BmlProfile> {
return try {
val json = extractInertiaJson(html) ?: html
val root = JSONObject(json)
val props = root.optJSONObject("props") ?: return emptyList()
val profiles = props.optJSONArray("profiles") ?: return emptyList()
(0 until profiles.length()).mapNotNull { i ->
val p = profiles.getJSONObject(i)
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
BmlProfile(
profileId = p.optString("profile_id"),
name = p.optString("name"),
type = p.optString("type"),
profileType = profileObj.optString("profile_type", "default")
)
}
} catch (_: Exception) { emptyList() }
}
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
return try {
val json = extractInertiaJson(html) ?: html
val root = JSONObject(json)
val props = root.optJSONObject("props") ?: return emptyList()
val channels = props.optJSONArray("channels") ?: return emptyList()
(0 until channels.length()).map { i ->
val c = channels.getJSONObject(i)
BmlOtpChannel(
channel = c.optString("channel"),
description = c.optString("description"),
masked = c.optString("masked")
)
}
} catch (_: Exception) { emptyList() }
}
private fun parseDashboard(
json: String,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<MibAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
@@ -612,14 +768,13 @@ class BmlLoginFlow {
val product = item.optString("product")
val accountNumber = item.optString("account")
val status = item.optString("account_status", "Active")
val internalId = item.optString("id", "")
if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount(
bank = "BML",
profileName = "Personal",
profileName = profileName,
profileType = "BML",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
@@ -632,18 +787,19 @@ class BmlLoginFlow {
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
if (!isVisible) continue
val isPrepaid = item.optBoolean("prepaid_card", false)
val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount(
bank = "BML",
profileName = "Personal",
profileName = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
@@ -656,6 +812,7 @@ class BmlLoginFlow {
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
}
@@ -723,6 +880,15 @@ class BmlLoginFlow {
return result
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
private fun xsrfToken(): String? =
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
@@ -748,4 +914,26 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
}
@@ -1,10 +1,35 @@
package sh.sar.basedbank.api.bml
import sh.sar.basedbank.api.mib.MibAccount
data class BmlSession(
val accessToken: String,
val deviceId: String
)
data class BmlProfile(
val profileId: String,
val name: String,
val type: String, // "Profile" (personal) or "Business"
val profileType: String // "default" or "business"
)
data class BmlOtpChannel(
val channel: String,
val description: String,
val masked: String
)
sealed class BmlActivationResult {
data class Success(
val session: BmlSession,
val accounts: List<MibAccount>
) : BmlActivationResult()
data class NeedsBusinessOtp(
val channels: List<BmlOtpChannel>
) : BmlActivationResult()
}
data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB
@@ -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.R
import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
@@ -115,7 +117,9 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
@@ -248,9 +252,22 @@ class HomeActivity : AppCompatActivity() {
}
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more)
.setIcon(R.drawable.ic_nav_more)
// Restore selection to current destination after menu rebuild
val currentId = binding.navigationView.checkedItem?.itemId
if (currentId != null) {
val bottomNavIds = (0 until menu.size()).map { menu.getItem(it).itemId }.toSet()
val selectId = if (currentId in bottomNavIds) currentId
else if (R.id.nav_more in bottomNavIds) R.id.nav_more
else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
}
fun applyNavLabelVisibility() {
fun applyNavLabelVisibility() {
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
binding.bottomNavigation.labelVisibilityMode =
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
@@ -263,6 +280,8 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
@@ -445,34 +464,84 @@ class HomeActivity : AppCompatActivity() {
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow()
val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId)
if (savedToken != null) {
try {
val session = BmlSession(savedToken.first, savedToken.second)
val accounts = bmlFlow.fetchAccounts(session, loginTag)
val app = application as BasedBankApp
app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
return@async Pair(session, accounts)
} catch (_: AuthExpiredException) {
} catch (_: Exception) {
val savedProfiles = store.loadBmlProfiles(loginId)
val allAccounts = mutableListOf<MibAccount>()
var anyExpired = savedProfiles.isEmpty()
// 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 {
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
val app = application as BasedBankApp
val session = BmlSession(legacyToken.first, legacyToken.second)
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
Pair(session, accounts)
} catch (_: Exception) {
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
allAccounts += accounts
anyExpired = false
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
}
}
if (anyExpired || allAccounts.isEmpty()) {
// Re-authenticate to refresh personal profile sessions
try {
val flow = app.bmlFlowFor(loginId)
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlProfiles(loginId, profiles)
app.bmlProfilesMap[loginId] = profiles
for (profile in profiles) {
if (profile.profileType == "business") {
// Can't activate business profiles without user OTP — use cached
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
if (allAccounts.none { it.profileId == profile.profileId })
allAccounts += cached
continue
}
try {
val result = flow.activateProfile(profile, loginTag)
if (result is BmlActivationResult.Success) {
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
app.bmlSessions[profile.profileId] = result.session
allAccounts.removeAll { it.profileId == profile.profileId }
allAccounts += result.accounts
}
} catch (_: Exception) {
if (allAccounts.none { it.profileId == profile.profileId }) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
}
}
}
} catch (_: Exception) {
if (allAccounts.isEmpty())
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
}
}
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
allAccounts as List<MibAccount>
}
}
// 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 mibAccounts = mibResults.flatMap { it.second }
val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
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> {
val store = CredentialStore(this@HomeActivity)
return filter { acc ->
if (acc.bank != "MIB") return@filter true
when (acc.bank) {
"MIB" -> {
val loginId = acc.loginTag.removePrefix("mib_")
val hidden = store.getHiddenMibProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
"BML" -> {
val loginId = acc.loginTag.removePrefix("bml_")
val hidden = store.getHiddenBmlProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
else -> true
}
}
}
/** Filters MIB profiles the user has hidden for a given loginId. */
@@ -585,10 +662,12 @@ class HomeActivity : AppCompatActivity() {
private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return
val store = CredentialStore(this)
lifecycleScope.launch {
try {
val allBmlContacts = withContext(Dispatchers.IO) {
app.bmlSessions.flatMap { (loginId, session) ->
store.getBmlLoginIds().flatMap { loginId ->
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts
@@ -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.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
@@ -78,24 +79,9 @@ class SettingsLoginsFragment : Fragment() {
for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct()
val bmlProfiles = store.loadBmlProfiles(loginId)
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
showLoginDetails(
title = getString(R.string.bml_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}")
if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}")
if (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
)
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
}
}
@@ -195,8 +181,8 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button
val checkboxRows = mibProfiles.map { p ->
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
val toggleRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
@@ -219,11 +205,20 @@ class SettingsLoginsFragment : Fragment() {
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(cb)
row.addView(toggle)
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)
@@ -238,12 +233,12 @@ class SettingsLoginsFragment : Fragment() {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
updateToggleStates(saveBtn)
checkboxRows.forEach { (p, cb) ->
cb.setOnCheckedChangeListener { _, checked ->
toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden }
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
updateToggleStates(saveBtn)
}
}
@@ -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) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
@@ -290,9 +405,14 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutBml(store: CredentialStore, loginId: String) {
val ctx = requireContext()
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions.remove(loginId)
// Remove all per-profile sessions for this login from the in-memory map
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
profiles.forEach { app.bmlSessions.remove(it.profileId) }
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
store.clearBmlCredentials(loginId)
app.bmlProfilesMap.remove(loginId)
app.bmlLoginFlows.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
@@ -14,7 +14,7 @@ import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
/** Adapter for Transaction History — date-grouped, shows account name in secondary line. */
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
@@ -134,7 +134,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History
// Show account name in secondary line for Transaction History
b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE
@@ -56,6 +56,7 @@ import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.Totp
class TransferFragment : Fragment() {
@@ -101,6 +102,8 @@ class TransferFragment : Fragment() {
private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_FROM_ACCOUNT = "from_account"
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
@@ -121,6 +124,22 @@ class TransferFragment : Fragment() {
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
}
}
fun newInstanceFromQr(
accountNumber: String,
displayName: String,
amount: String?,
remarks: String?
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B")
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -155,7 +174,7 @@ class TransferFragment : Fragment() {
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 ->
prefillToDirectly(
accountNumber = account,
@@ -165,6 +184,8 @@ class TransferFragment : Fragment() {
imageHash = arguments?.getString(ARG_IMAGE_HASH)
)
}
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
}
private fun startLookupLoading() {
@@ -628,6 +649,7 @@ class TransferFragment : Fragment() {
binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
clearForm()
val activity = requireActivity() as HomeActivity
activity.refreshBalances(src)
@@ -755,7 +777,7 @@ class TransferFragment : Fragment() {
)
if (result.success) {
val receipt = TransferReceiptData(
isMib = true,
bank = "MIB",
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency,
fromLabel = src.accountBriefName,
@@ -839,7 +861,7 @@ class TransferFragment : Fragment() {
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val receipt = TransferReceiptData(
isMib = false,
bank = "BML",
amount = "%.2f".format(amount),
currency = currency,
fromLabel = src.accountBriefName,
@@ -1,7 +1,7 @@
package sh.sar.basedbank.ui.home
data class TransferReceiptData(
val isMib: Boolean,
val bank: String, // "MIB", "BML", etc.
val amount: String,
val currency: String,
val fromLabel: String,
@@ -1,6 +1,10 @@
package sh.sar.basedbank.ui.home
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
private val receiptCard get() = _receiptCard!!
companion object {
private const val ARG_IS_MIB = "is_mib"
private const val ARG_BANK = "bank"
private const val ARG_AMOUNT = "amount"
private const val ARG_CURRENCY = "currency"
private const val ARG_FROM_LABEL = "from_label"
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
pendingToAvatarBitmap = toAvatarBitmap
arguments = Bundle().apply {
putBoolean(ARG_IS_MIB, data.isMib)
putString(ARG_BANK, data.bank)
putString(ARG_AMOUNT, data.amount)
putString(ARG_CURRENCY, data.currency)
putString(ARG_FROM_LABEL, data.fromLabel)
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
return if (isMib) {
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
return if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding)
_receiptCard = binding.receiptCard
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() }
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack()
}
@@ -150,6 +156,12 @@ class TransferReceiptFragment : Fragment() {
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
copyOnLongClick(
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
)
}
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
binding.remarksDivider.visibility = View.VISIBLE
binding.remarksRow.visibility = View.VISIBLE
}
copyOnLongClick(
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
binding.tvAmountCurrency, binding.tvRemarks
)
}
// ── Share / Save ──────────────────────────────────────────────────────────
@@ -310,6 +329,54 @@ class TransferReceiptFragment : Fragment() {
return bm
}
private fun showFullScreenReceipt() {
captureReceiptBitmap { bitmap ->
if (bitmap == null) return@captureReceiptBitmap
val ctx = requireContext()
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val iv = android.widget.ImageView(ctx).apply {
setImageBitmap(bitmap)
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
setBackgroundColor(Color.BLACK)
}
iv.setOnClickListener { dialog.dismiss() }
dialog.setContentView(iv)
val actWin = requireActivity().window
val prevColor = actWin.statusBarColor
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
actWin.statusBarColor = Color.BLACK
insetsCtrl.isAppearanceLightStatusBars = false
dialog.setOnDismissListener {
actWin.statusBarColor = prevColor
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
insetsCtrl.isAppearanceLightStatusBars = isLight
}
dialog.show()
dialog.window?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
androidx.core.view.WindowInsetsControllerCompat(win, iv).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
}
private fun copyOnLongClick(vararg views: android.widget.TextView) {
for (tv in views) {
tv.setOnLongClickListener {
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
if (text.isBlank()) return@setOnLongClickListener false
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
true
}
}
}
override fun onResume() {
super.onResume()
requireActivity().title = "Receipt"
@@ -18,14 +18,21 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CredentialsFragment : Fragment() {
@@ -46,6 +53,15 @@ class CredentialsFragment : Fragment() {
private var fahipayFlow: FahipayLoginFlow? = null
private var fahipayAwaitingTotp = false
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
private var bmlCurrentBusinessProfile: BmlProfile? = null
private var bmlSelectedChannel: String? = null
private var bmlAwaitingBusinessOtp = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root
@@ -151,7 +167,11 @@ class CredentialsFragment : Fragment() {
private fun attemptLogin() {
when (bankType) {
"BML" -> { attemptBmlLogin(); return }
"BML" -> {
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
else attemptBmlLogin()
return
}
"FAHIPAY" -> { attemptFahipayLogin(); return }
}
@@ -232,20 +252,185 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val loginId = username
val flow = BmlLoginFlow()
bmlLoginId = username
bmlAccumulatedAccounts.clear()
bmlPendingBusinessProfiles.clear()
bmlCurrentBusinessProfile = null
bmlSelectedChannel = null
bmlAwaitingBusinessOtp = false
val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username"
viewLifecycleOwner.lifecycleScope.launch {
try {
val profiles = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed)
}
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
// Activate each profile; personal profiles are immediate, business ones need OTP
for (profile in profiles) {
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
when (result) {
is BmlActivationResult.Success -> {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
is BmlActivationResult.NeedsBusinessOtp -> {
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
}
}
}
// Save credentials and profile list now (before business OTP prompts)
val store = CredentialStore(requireContext())
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlProfiles(bmlLoginId, profiles)
val app = requireActivity().application as BasedBankApp
app.bmlProfilesMap[bmlLoginId] = profiles
app.bmlLoginFlows[bmlLoginId] = flow
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun processNextBmlBusinessProfile() {
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
?: run { finishBmlLogin(); return }
bmlCurrentBusinessProfile = profile
// Show channel selection dialog
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
// User skipped this profile — move on
processNextBmlBusinessProfile()
return
}
bmlSelectedChannel = selectedChannel
// Request OTP
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
try {
withContext(Dispatchers.IO) {
bmlFlow!!.requestBusinessOtp(selectedChannel)
}
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
binding.tvError.text = e.message ?: "Failed to send OTP"
binding.tvError.visibility = View.VISIBLE
processNextBmlBusinessProfile()
return
}
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
bmlAwaitingBusinessOtp = true
binding.etUsername.isEnabled = false
binding.etPassword.isEnabled = false
binding.etOtpSeed.isEnabled = false
binding.progressBar.visibility = View.GONE
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
binding.tilTotpCode.visibility = View.VISIBLE
binding.etTotpCode.text?.clear()
binding.btnLogin.text = getString(R.string.verify)
binding.btnLogin.isEnabled = true
binding.tvError.visibility = View.GONE
}
private fun submitBmlBusinessOtp() {
val code = binding.etTotpCode.text.toString().trim()
if (code.length != 6) {
binding.tvError.text = getString(R.string.fahipay_totp_hint)
binding.tvError.visibility = View.VISIBLE
return
}
binding.tvError.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val profile = bmlCurrentBusinessProfile ?: return
val channel = bmlSelectedChannel ?: return
val loginTag = "bml_$bmlLoginId"
viewLifecycleOwner.lifecycleScope.launch {
try {
val (session, accounts) = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed)
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
}
bmlAccumulatedAccounts += accounts
val store = CredentialStore(requireContext())
store.saveBmlCredentials(loginId, username, password, otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = session
bmlAwaitingBusinessOtp = false
binding.tilTotpCode.visibility = View.GONE
binding.btnLogin.text = getString(R.string.login)
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "OTP verification failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.bml_business_otp_title, profileName))
.setItems(options) { _, which ->
if (cont.isActive) cont.resume(channels[which].channel)
}
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
if (cont.isActive) cont.resume(null as String?)
}
.show()
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
}
private suspend fun finishBmlLogin() {
val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList()
// Fetch user profile info from any active session
val app = requireActivity().application as BasedBankApp
val anySession = app.anyBmlSessionFor(bmlLoginId)
if (anySession != null) {
withContext(Dispatchers.IO) {
val info = flow.fetchUserInfo(session)
val info = BmlLoginFlow().fetchUserInfo(anySession)
if (info != null) store.saveBmlUserProfile(
loginId,
bmlLoginId,
CredentialStore.BmlUserProfile(
fullName = info.fullName,
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
// Merge with any existing BML accounts from other logins
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
app.accounts = app.accounts + accounts
}
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} 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() {
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlProfile
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -193,24 +194,83 @@ class CredentialStore(context: Context) {
}
fun clearBmlCredentials(loginId: String) {
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
removeBmlLoginId(loginId)
prefs.edit()
.remove("bml_${loginId}_enc_username")
.remove("bml_${loginId}_enc_password")
.remove("bml_${loginId}_enc_otp_seed")
.remove("bml_${loginId}_profiles")
.remove("bml_${loginId}_hidden_profile_ids")
// legacy single-profile session keys
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.apply()
}
// ── BML session token (per loginId) ───────────────────────────────────────
// ── BML profiles (per loginId) ────────────────────────────────────────────
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("type", p.type)
put("profileType", p.profileType)
})
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()
prefs.edit()
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
.apply()
}
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
return try {
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
} catch (_: Exception) { null }
}
fun clearBmlProfileSession(profileId: String) {
prefs.edit()
.remove("bml_profile_${profileId}_enc_token")
.remove("bml_profile_${profileId}_enc_device_id")
.apply()
}
// ── Legacy single-profile BML session (kept for backward compat reading) ─
fun loadBmlSession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
@@ -220,13 +280,6 @@ class CredentialStore(context: Context) {
} 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) ──
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:visibility="gone"
android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" />
app:menu="@menu/bottom_nav_menu" />
</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"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
@@ -225,7 +226,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Share" />
android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
@@ -234,7 +236,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:text="Save" />
android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
@@ -253,7 +253,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Share" />
android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
@@ -262,7 +263,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:text="Save" />
android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
+1 -1
View File
@@ -70,7 +70,7 @@
<string name="nav_accounts">އެކައުންޓްތައް</string>
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
<string name="nav_activities">ހަރަކާތްތައް</string>
<string name="nav_transfer_history">ޓްރާންސްފަ ތާރީހް</string>
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string>
<string name="nav_finances">ފައިނޭންސް</string>
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
<string name="nav_settings">ސެޓިންގ</string>
+18 -2
View File
@@ -79,8 +79,8 @@
<string name="nav_add_account">Add Login</string>
<string name="nav_accounts">Accounts</string>
<string name="nav_contacts">Contacts</string>
<string name="nav_activities">Activities</string>
<string name="nav_transfer_history">Transfer History</string>
<string name="nav_activities">Recent Transfers</string>
<string name="nav_transfer_history">Transaction History</string>
<string name="nav_finances">Finances</string>
<string name="nav_card_settings">Card Settings</string>
<string name="nav_otp">OTP Codes</string>
@@ -99,6 +99,15 @@
<string name="transfer">Transfer</string>
<string name="pay_mv_qr">PayMV QR</string>
<!-- PayMV QR Generator -->
<string name="paymvqr_select_account">Select account</string>
<string name="paymvqr_amount_hint">Amount (optional)</string>
<string name="paymvqr_amount_helper">Leave empty to allow payer to enter any amount</string>
<string name="paymvqr_share">Share</string>
<string name="paymvqr_save_image">Save Image</string>
<string name="paymvqr_saved">QR saved to gallery</string>
<string name="paymvqr_save_failed">Failed to save image</string>
<!-- Toolbar -->
<string name="action_lock">Lock app</string>
<string name="autolock_warning_title">Locking soon</string>
@@ -159,6 +168,13 @@
<string name="close">Close</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<string name="verify">Verify</string>
<!-- BML business OTP -->
<string name="bml_business_otp_title">Activate %s profile</string>
<string name="bml_business_otp_hint">%s OTP code</string>
<string name="bml_business_otp_sent">OTP sent via %s</string>
<string name="bml_business_otp_skip">Skip</string>
<!-- Home -->
<string name="transfer_same_account">This is your source account</string>
+1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="receipt_cache" path="receipts/" />
<cache-path name="qr_cache" path="qr/" />
</paths>