Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
58f1b9fd6f
|
|||
|
240d04ad74
|
|||
|
fe507073b1
|
|||
|
6d48c27391
|
|||
|
e894f81887
|
|||
|
acc1278b34
|
|||
|
bc678d26ad
|
|||
|
bb2a80a5e3
|
|||
|
b107358266
|
|||
|
02a53c8219
|
|||
|
15a02cac1c
|
|||
|
35a1748055
|
|||
|
28682bba41
|
|||
|
25484addfb
|
|||
|
728c7d2aa3
|
|||
|
b24949c117
|
|||
|
28e5878668
|
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.0.4"
|
||||
versionCode = 4
|
||||
versionName = "1.0.5"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -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,9 +4,11 @@ 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
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
@@ -15,22 +17,34 @@ import sh.sar.basedbank.util.CredentialStore
|
||||
class BasedBankApp : Application() {
|
||||
|
||||
// Held in memory after successful login; cleared on logout
|
||||
var accounts: List<MibAccount> = emptyList()
|
||||
var accounts: List<BankAccount> = emptyList()
|
||||
var fullName: String = ""
|
||||
|
||||
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
||||
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
||||
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
||||
var mibAccounts: List<MibAccount> = emptyList()
|
||||
/** Active BML sessions keyed by loginId (= BML username). */
|
||||
var mibAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/**
|
||||
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
|
||||
* Use [bmlSessionFor] to look up the session for an account.
|
||||
*/
|
||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||
var bmlAccounts: List<MibAccount> = emptyList()
|
||||
/** BML profiles per loginId (= BML username). */
|
||||
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||
var bmlAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||
fun mibSessionFor(account: MibAccount): MibSession? =
|
||||
fun mibSessionFor(account: BankAccount): MibSession? =
|
||||
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||
|
||||
/** Returns any available MIB session. */
|
||||
@@ -53,15 +67,40 @@ 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: BankAccount): BmlSession? {
|
||||
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||
}
|
||||
|
||||
/** Returns any available BML session (for non-account-specific operations). */
|
||||
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? =
|
||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
|
||||
124
app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt
Normal file
124
app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val customerId: String,
|
||||
val idCard: String,
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
class BmlAccountClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchAccounts(
|
||||
session: BmlSession,
|
||||
loginTag: String,
|
||||
profileName: String = "Personal",
|
||||
profileId: String = ""
|
||||
): List<BankAccount> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||
}
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||
BmlUserInfo(
|
||||
fullName = user.optString("fullname").trim(),
|
||||
email = user.optString("email").trim(),
|
||||
mobile = user.optString("mobile_phone").trim(),
|
||||
customerId = user.optString("customer_number").trim(),
|
||||
idCard = user.optString("idcard").trim(),
|
||||
birthdate = user.optString("birthdate").trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun parseDashboard(
|
||||
json: String,
|
||||
loginTag: String,
|
||||
profileName: String,
|
||||
profileId: String
|
||||
): List<BankAccount> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||
|
||||
val casaAccounts = mutableListOf<BankAccount>()
|
||||
val prepaidCards = mutableListOf<BankAccount>()
|
||||
|
||||
for (i in 0 until dashboard.length()) {
|
||||
val item = dashboard.getJSONObject(i)
|
||||
val currency = item.optString("currency", "MVR")
|
||||
val accountType = item.optString("account_type", "CASA")
|
||||
val product = item.optString("product")
|
||||
val accountNumber = item.optString("account")
|
||||
val status = item.optString("account_status", "Active")
|
||||
val internalId = item.optString("id", "")
|
||||
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
if (!isVisible) continue
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
prepaidCards.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + prepaidCards
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
internal const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||
|
||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
|
||||
class BmlContactsClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<BankContact> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
|
||||
fun saveContact(
|
||||
session: BmlSession,
|
||||
contactType: String,
|
||||
account: String,
|
||||
alias: String,
|
||||
currency: String? = null,
|
||||
name: String? = null,
|
||||
swift: String? = null
|
||||
): Boolean {
|
||||
val bodyObj = JSONObject().apply {
|
||||
put("contact_type", contactType)
|
||||
put("account", account)
|
||||
put("alias", alias)
|
||||
if (currency != null) put("currency", currency)
|
||||
if (name != null) put("name", name)
|
||||
if (swift != null) put("swift", swift)
|
||||
}
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts")
|
||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts/$contactId")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val bodyStr = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun parseContacts(json: String, loginId: String): List<BankContact> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankContact>()
|
||||
for (i in 0 until payload.length()) {
|
||||
val item = payload.getJSONObject(i)
|
||||
val account = item.optString("account", "")
|
||||
if (account.isBlank()) continue
|
||||
result.add(BankContact(
|
||||
benefNo = "bml_${item.optInt("id")}",
|
||||
benefName = item.optString("name"),
|
||||
benefNickName = item.optString("alias", item.optString("name")),
|
||||
benefAccount = account,
|
||||
benefType = "I",
|
||||
bankColor = "#0066A1",
|
||||
benefBankName = "Bank of Maldives",
|
||||
bankCode = "",
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BmlForeignLimitsClient {
|
||||
|
||||
// Foreign limits use a different host than the main BML API
|
||||
private val BASE_URL = "https://app.bankofmaldives.com.mv/api/v2"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/foreign-limits")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseForeignLimits(json ?: return emptyList())
|
||||
}
|
||||
|
||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||
BmlForeignLimit(
|
||||
type = item.optString("type", "Debit"),
|
||||
used = item.optDouble("used", 0.0),
|
||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||
generalCap = item.optDouble("generalCap", 0.0),
|
||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||
atmLimit = atm.optDouble("limit", 0.0),
|
||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||
posRemaining = pos.optDouble("remaining", 0.0),
|
||||
posLimit = pos.optDouble("limit", 0.0)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
}
|
||||
174
app/src/main/java/sh/sar/basedbank/api/bml/BmlHistoryClient.kt
Normal file
174
app/src/main/java/sh/sar/basedbank/api/bml/BmlHistoryClient.kt
Normal file
@@ -0,0 +1,174 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BmlHistoryClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchAccountHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
page: Int
|
||||
): Pair<List<BankTransaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||
val totalPages = payload.optInt("totalPages", 0)
|
||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||
val transactions = (0 until history.length()).map { i ->
|
||||
val item = history.getJSONObject(i)
|
||||
val desc = item.optString("description").trim()
|
||||
val narrative1 = item.optString("narrative1")
|
||||
val date = when (desc) {
|
||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
else -> item.optString("bookingDate")
|
||||
}
|
||||
BankTransaction(
|
||||
id = item.optString("id"),
|
||||
date = date,
|
||||
description = desc,
|
||||
amount = item.optDouble("amount", 0.0),
|
||||
currency = item.optString("currency"),
|
||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
Pair(transactions, totalPages)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
fun fetchCardHistory(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<BankTransaction> {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/card/statement").post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankTransaction>()
|
||||
|
||||
val authDetails = payload.optJSONObject("outstanding")
|
||||
?.optJSONArray("CardOutStdAuthDetails")
|
||||
if (authDetails != null) {
|
||||
for (i in 0 until authDetails.length()) {
|
||||
val item = authDetails.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val unbilled = payload.optJSONObject("unbilled")
|
||||
?.optJSONArray("CardUnbillTxnDetails")
|
||||
if (unbilled != null) {
|
||||
for (i in 0 until unbilled.length()) {
|
||||
val item = unbilled.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val parts = narrative1.split(" ")
|
||||
if (parts.size < 2) null
|
||||
else {
|
||||
val timePart = parts[1].take(4)
|
||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,15 @@ import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AuthExpiredException : Exception("Session expired")
|
||||
@@ -29,9 +25,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 {
|
||||
@@ -53,14 +49,27 @@ class BmlLoginFlow {
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val apiClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||
private var codeVerifier: String = ""
|
||||
private var codeChallenge: String = ""
|
||||
private var deviceId: String = ""
|
||||
|
||||
/** 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
|
||||
/** 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 +91,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 +112,161 @@ class BmlLoginFlow {
|
||||
twoFaResp.close()
|
||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||
|
||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
||||
client.newCall(
|
||||
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||
// blaze_identity already set in the response cookies.
|
||||
val profileResp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile")
|
||||
.header("X-XSRF-TOKEN", xsrf2)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val profileCode = profileResp.code
|
||||
val profileLocation = profileResp.header("Location") ?: ""
|
||||
val profileBody = profileResp.body?.string() ?: ""
|
||||
profileResp.close()
|
||||
|
||||
lastProfiles = if (profileCode == 302) {
|
||||
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||
// and blaze_identity is already set — no profile picker shown.
|
||||
// Use username as a stable temporary profileId (unique per login); it will be
|
||||
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||
} else {
|
||||
parseProfiles(profileBody)
|
||||
}
|
||||
return lastProfiles
|
||||
}
|
||||
|
||||
// ─── Profile activation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Activates a profile in the current web session and returns the result.
|
||||
*
|
||||
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||
*/
|
||||
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||
if (profile.autoActivated) {
|
||||
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
return BmlActivationResult.Success(session, accounts)
|
||||
}
|
||||
|
||||
val xsrf = xsrfToken()
|
||||
val reqBuilder = Request.Builder()
|
||||
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||
.header("User-Agent", WEB_USER_AGENT)
|
||||
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||
|
||||
val resp = client.newCall(reqBuilder.build()).execute()
|
||||
val code = resp.code
|
||||
val location = resp.header("Location") ?: ""
|
||||
resp.close()
|
||||
|
||||
return when {
|
||||
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||
// Profile activated — blaze_identity cookie set in response headers.
|
||||
// Any 302 that isn't to the business 2FA page means success.
|
||||
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
BmlActivationResult.Success(session, accounts)
|
||||
}
|
||||
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||
// Business profile: server requires SMS/email OTP
|
||||
val channels = fetchBusinessOtpChannels()
|
||||
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||
}
|
||||
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available OTP channels for the business 2FA page.
|
||||
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||
*/
|
||||
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
return parseBusinessOtpChannels(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an OTP to [channel] for business profile activation.
|
||||
* Must be called before [submitBusinessOtp].
|
||||
*/
|
||||
fun requestBusinessOtp(channel: String) {
|
||||
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||
val body = JSONObject().apply {
|
||||
put("code", "")
|
||||
put("channel", channel)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val respCode = resp.code
|
||||
resp.close()
|
||||
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the OTP and activates the business profile.
|
||||
* Returns a new [BmlSession] and accounts on success.
|
||||
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||
*/
|
||||
fun submitBusinessOtp(
|
||||
channel: String,
|
||||
code: String,
|
||||
profile: BmlProfile,
|
||||
loginTag: String
|
||||
): Pair<BmlSession, List<BankAccount>> {
|
||||
// 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<BankAccount>> {
|
||||
val authorizeUrl = HttpUrl.Builder()
|
||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||
.addPathSegments("internetbanking/oauth/authorize")
|
||||
@@ -135,14 +286,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,568 +310,64 @@ 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 = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||
return Pair(session, accounts)
|
||||
}
|
||||
|
||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
||||
}
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseForeignLimits(json ?: return emptyList())
|
||||
}
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val customerId: String,
|
||||
val idCard: String,
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||
BmlUserInfo(
|
||||
fullName = user.optString("fullname").trim(),
|
||||
email = user.optString("email").trim(),
|
||||
mobile = user.optString("mobile_phone").trim(),
|
||||
customerId = user.optString("customer_number").trim(),
|
||||
idCard = user.optString("idcard").trim(),
|
||||
birthdate = user.optString("birthdate").trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val payload = root.optJSONObject("payload") ?: return null
|
||||
val trnType = payload.optString("trnType", "")
|
||||
val validationType = payload.optString("validationType", "")
|
||||
if (validationType == "alias") {
|
||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = cdtrAcct.optString("Acct"),
|
||||
originalInput = input,
|
||||
name = payload.optString("contact_name").trim(),
|
||||
alias = null,
|
||||
currency = payload.optString("currency", "MVR"),
|
||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = payload.optString("account"),
|
||||
originalInput = input,
|
||||
name = payload.optString("name"),
|
||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||
currency = payload.optString("currency", "MVR")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
account = root.optString("account"),
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveContact(
|
||||
session: BmlSession,
|
||||
contactType: String,
|
||||
account: String,
|
||||
alias: String,
|
||||
currency: String? = null,
|
||||
name: String? = null,
|
||||
swift: String? = null
|
||||
): Boolean {
|
||||
val bodyObj = JSONObject().apply {
|
||||
put("contact_type", contactType)
|
||||
put("account", account)
|
||||
put("alias", alias)
|
||||
if (currency != null) put("currency", currency)
|
||||
if (name != null) put("name", name)
|
||||
if (swift != null) put("swift", swift)
|
||||
}
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/contacts")
|
||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
||||
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||
*/
|
||||
fun initiateTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/transfer")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
private fun extractInertiaJson(html: String): String? {
|
||||
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||
return match.groupValues[1]
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
||||
*/
|
||||
fun confirmTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/transfer")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
success = true,
|
||||
reference = payload?.optString("reference") ?: "",
|
||||
timestamp = payload?.optString("timestamp") ?: "",
|
||||
message = json.optString("message")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/contacts/$contactId")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val parts = narrative1.split(" ")
|
||||
if (parts.size < 2) null
|
||||
else {
|
||||
val timePart = parts[1].take(4)
|
||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches paginated transaction history for a BML CASA account.
|
||||
* @return Pair of (transactions, totalPages)
|
||||
*/
|
||||
fun fetchAccountHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
page: Int
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||
return try {
|
||||
val json = extractInertiaJson(html) ?: html
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||
val totalPages = payload.optInt("totalPages", 0)
|
||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||
val transactions = (0 until history.length()).map { i ->
|
||||
val item = history.getJSONObject(i)
|
||||
val desc = item.optString("description").trim()
|
||||
val narrative1 = item.optString("narrative1")
|
||||
val date = when (desc) {
|
||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
else -> item.optString("bookingDate")
|
||||
}
|
||||
Transaction(
|
||||
id = item.optString("id"),
|
||||
date = date,
|
||||
description = desc,
|
||||
amount = item.optDouble("amount", 0.0),
|
||||
currency = item.optString("currency"),
|
||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
Pair(transactions, totalPages)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
||||
* Returns combined outstanding authorizations + settled statement entries.
|
||||
*/
|
||||
fun fetchCardHistory(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<Transaction> {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<Transaction>()
|
||||
|
||||
// Outstanding authorizations
|
||||
val authDetails = payload.optJSONObject("outstanding")
|
||||
?.optJSONArray("CardOutStdAuthDetails")
|
||||
if (authDetails != null) {
|
||||
for (i in 0 until authDetails.length()) {
|
||||
val item = authDetails.getJSONObject(i)
|
||||
result.add(Transaction(
|
||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Settled statement entries
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(Transaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun apiRequest(session: BmlSession, url: String) =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
|
||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||
|
||||
val casaAccounts = mutableListOf<MibAccount>()
|
||||
val prepaidCards = mutableListOf<MibAccount>()
|
||||
|
||||
for (i in 0 until dashboard.length()) {
|
||||
val item = dashboard.getJSONObject(i)
|
||||
val currency = item.optString("currency", "MVR")
|
||||
val accountType = item.optString("account_type", "CASA")
|
||||
val product = item.optString("product")
|
||||
val accountNumber = item.optString("account")
|
||||
val status = item.optString("account_status", "Active")
|
||||
|
||||
val internalId = item.optString("id", "")
|
||||
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(MibAccount(
|
||||
bank = "BML",
|
||||
profileName = "Personal",
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
prepaidCards.add(MibAccount(
|
||||
bank = "BML",
|
||||
profileName = "Personal",
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + prepaidCards
|
||||
}
|
||||
|
||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||
BmlForeignLimit(
|
||||
type = item.optString("type", "Debit"),
|
||||
used = item.optDouble("used", 0.0),
|
||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||
generalCap = item.optDouble("generalCap", 0.0),
|
||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||
atmLimit = atm.optDouble("limit", 0.0),
|
||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||
posRemaining = pos.optDouble("remaining", 0.0),
|
||||
posLimit = pos.optDouble("limit", 0.0)
|
||||
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 parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
val result = mutableListOf<MibBeneficiary>()
|
||||
for (i in 0 until payload.length()) {
|
||||
val item = payload.getJSONObject(i)
|
||||
val account = item.optString("account", "")
|
||||
if (account.isBlank()) continue
|
||||
result.add(MibBeneficiary(
|
||||
benefNo = "bml_${item.optInt("id")}",
|
||||
benefName = item.optString("name"),
|
||||
benefNickName = item.optString("alias", item.optString("name")),
|
||||
benefAccount = account,
|
||||
benefType = "I",
|
||||
bankColor = "#0066A1",
|
||||
benefBankName = "Bank of Maldives",
|
||||
bankCode = "",
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
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 xsrfToken(): String? =
|
||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||
|
||||
@@ -748,4 +393,5 @@ class BmlLoginFlow {
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
data class BmlSession(
|
||||
val accessToken: String,
|
||||
val deviceId: String
|
||||
)
|
||||
|
||||
data class BmlProfile(
|
||||
val profileId: String,
|
||||
val name: String,
|
||||
val type: String, // "Profile" (personal) or "Business"
|
||||
val profileType: String, // "default" or "business"
|
||||
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||
)
|
||||
|
||||
data class BmlOtpChannel(
|
||||
val channel: String,
|
||||
val description: String,
|
||||
val masked: String
|
||||
)
|
||||
|
||||
sealed class BmlActivationResult {
|
||||
data class Success(
|
||||
val session: BmlSession,
|
||||
val accounts: List<BankAccount>
|
||||
) : 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,98 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlTransferClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/** Step 1: initiate the transfer (triggers OTP). Returns true if the server accepted it. */
|
||||
fun initiateTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: confirm with OTP. Returns a [BmlTransferResult] with success/reference/error. */
|
||||
fun confirmTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
success = true,
|
||||
reference = payload?.optString("reference") ?: "",
|
||||
timestamp = payload?.optString("timestamp") ?: "",
|
||||
message = json.optString("message")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlValidateClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/validate/account/$input")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val payload = root.optJSONObject("payload") ?: return null
|
||||
val trnType = payload.optString("trnType", "")
|
||||
val validationType = payload.optString("validationType", "")
|
||||
if (validationType == "alias") {
|
||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = cdtrAcct.optString("Acct"),
|
||||
originalInput = input,
|
||||
name = payload.optString("contact_name").trim(),
|
||||
alias = null,
|
||||
currency = payload.optString("currency", "MVR"),
|
||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = payload.optString("account"),
|
||||
originalInput = input,
|
||||
name = payload.optString("name"),
|
||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||
currency = payload.optString("currency", "MVR")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
account = root.optString("account"),
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayAccountClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun Request.Builder.auth(session: FahipaySession): Request.Builder = this
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA)
|
||||
|
||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
fullName = obj.optString("fullname").trim(),
|
||||
email = obj.optString("email").trim(),
|
||||
mobile = obj.optString("mobile").trim(),
|
||||
nid = obj.optString("nid").trim(),
|
||||
profileId = obj.optString("profileID").trim(),
|
||||
walletAccount = props.optString("acc", ""),
|
||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchBalance(session: FahipaySession): Double {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
} catch (_: Exception) { 0.0 }
|
||||
}
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): BankAccount =
|
||||
BankAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
accountBriefName = "Fahipay Wallet",
|
||||
currencyName = "MVR",
|
||||
accountTypeName = "Digital Wallet",
|
||||
availableBalance = "%.2f".format(balance),
|
||||
currentBalance = "%.2f".format(balance),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "%.2f".format(balance),
|
||||
statusDesc = "Active",
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = profile.profileId
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayContactsClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||
val endpoints = listOf(
|
||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||
)
|
||||
val result = mutableListOf<FahipayContactGroup>()
|
||||
for ((catId, label, page) in endpoints) {
|
||||
try {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: continue
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
val groupObj = obj.optJSONObject(page) ?: continue
|
||||
val contacts = mutableListOf<BankContact>()
|
||||
for (key in groupObj.keys()) {
|
||||
val entry = groupObj.getJSONObject(key)
|
||||
val number = entry.optString("number")
|
||||
val name = entry.optString("name").trim().ifBlank { number }
|
||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||
contacts.add(BankContact(
|
||||
benefNo = "fp_${page}_$number",
|
||||
benefName = "",
|
||||
benefNickName = name,
|
||||
benefAccount = number,
|
||||
benefType = "FAHIPAY",
|
||||
bankColor = "#FF6B00",
|
||||
benefBankName = label,
|
||||
bankCode = "",
|
||||
benefStatus = "",
|
||||
transferCyDesc = "",
|
||||
customerImgHash = null,
|
||||
benefCategoryId = catId,
|
||||
profileId = ""
|
||||
))
|
||||
}
|
||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayHistoryClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
private val PAGE_SIZE = 15
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchHistory(
|
||||
session: FahipaySession,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
start: Int
|
||||
): Pair<List<BankTransaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||
val list = (0 until entries.length()).map { i ->
|
||||
val e = entries.getJSONObject(i)
|
||||
BankTransaction(
|
||||
id = e.optString("transaction"),
|
||||
date = e.optString("date"),
|
||||
description = e.optString("name").trim(),
|
||||
amount = e.optDouble("amount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "FAHIPAY",
|
||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
Pair(list, total)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,6 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -21,8 +17,6 @@ class FahipayLoginFlow {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
private val UA_OKHTTP = "okhttp/4.12.0"
|
||||
private val PAGE_SIZE = 15
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
@@ -144,165 +138,6 @@ class FahipayLoginFlow {
|
||||
?: throw Exception("No authID in OTP response")
|
||||
}
|
||||
|
||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
fullName = obj.optString("fullname").trim(),
|
||||
email = obj.optString("email").trim(),
|
||||
mobile = obj.optString("mobile").trim(),
|
||||
nid = obj.optString("nid").trim(),
|
||||
profileId = obj.optString("profileID").trim(),
|
||||
walletAccount = props.optString("acc", ""),
|
||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchBalance(session: FahipaySession): Double {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
} catch (_: Exception) { 0.0 }
|
||||
}
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
||||
MibAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
accountBriefName = "Fahipay Wallet",
|
||||
currencyName = "MVR",
|
||||
accountTypeName = "Digital Wallet",
|
||||
availableBalance = "%.2f".format(balance),
|
||||
currentBalance = "%.2f".format(balance),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "%.2f".format(balance),
|
||||
statusDesc = "Active",
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = profile.profileId
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches paginated activity history.
|
||||
* @param start offset (0-based)
|
||||
* @return Pair of (transactions, total count)
|
||||
*/
|
||||
fun fetchHistory(
|
||||
session: FahipaySession,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
start: Int
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||
val list = (0 until entries.length()).map { i ->
|
||||
val e = entries.getJSONObject(i)
|
||||
Transaction(
|
||||
id = e.optString("transaction"),
|
||||
date = e.optString("date"),
|
||||
description = e.optString("name").trim(),
|
||||
amount = e.optDouble("amount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "FAHIPAY",
|
||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
Pair(list, total)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Fahipay saved favourites for the 4 service groups.
|
||||
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
|
||||
* Groups with no valid entries are omitted.
|
||||
*/
|
||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||
val endpoints = listOf(
|
||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||
)
|
||||
val result = mutableListOf<FahipayContactGroup>()
|
||||
for ((catId, label, page) in endpoints) {
|
||||
try {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: continue
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
|
||||
val groupObj = obj.optJSONObject(page) ?: continue
|
||||
val contacts = mutableListOf<MibBeneficiary>()
|
||||
for (key in groupObj.keys()) {
|
||||
val entry = groupObj.getJSONObject(key)
|
||||
val number = entry.optString("number")
|
||||
val name = entry.optString("name").trim().ifBlank { number }
|
||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||
contacts.add(MibBeneficiary(
|
||||
benefNo = "fp_${page}_$number",
|
||||
benefName = "",
|
||||
benefNickName = name,
|
||||
benefAccount = number,
|
||||
benefType = "FAHIPAY",
|
||||
bankColor = "#FF6B00",
|
||||
benefBankName = label,
|
||||
bankCode = "",
|
||||
benefStatus = "",
|
||||
transferCyDesc = "",
|
||||
customerImgHash = null,
|
||||
benefCategoryId = catId,
|
||||
profileId = ""
|
||||
))
|
||||
}
|
||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||
"device[available]" to "true",
|
||||
"device[platform]" to "Android",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
|
||||
data class FahipaySession(
|
||||
val authId: String,
|
||||
val sessionCookie: String
|
||||
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
|
||||
data class FahipayContactGroup(
|
||||
val categoryId: String,
|
||||
val label: String,
|
||||
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
|
||||
val contacts: List<BankContact>
|
||||
)
|
||||
|
||||
@@ -356,44 +356,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
}
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
|
||||
/** Fetches the customer's profile info from the Faisanet personal profile page. */
|
||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
|
||||
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
||||
val payload = baseData(session, "P41").apply {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
|
||||
// Kept for source compatibility within the mib package
|
||||
typealias MibAccount = BankAccount
|
||||
typealias MibBeneficiary = BankContact
|
||||
typealias Transaction = BankTransaction
|
||||
typealias MibBeneficiaryCategory = BankContactCategory
|
||||
|
||||
data class MibSession(
|
||||
val appId: String,
|
||||
val xxid: String,
|
||||
@@ -19,25 +30,6 @@ data class MibProfile(
|
||||
val customerImage: String?
|
||||
)
|
||||
|
||||
data class MibAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String,
|
||||
val profileImageHash: String?,
|
||||
val loginTag: String = "",
|
||||
val profileId: String = "", // MIB profile ID; empty for BML accounts
|
||||
val internalId: String = "" // BML internal UUID; empty for MIB accounts
|
||||
)
|
||||
|
||||
data class MibTransferResult(
|
||||
val success: Boolean,
|
||||
@@ -46,27 +38,6 @@ data class MibTransferResult(
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class MibBeneficiaryCategory(
|
||||
val id: String,
|
||||
val categoryName: String,
|
||||
val numBenef: Int
|
||||
)
|
||||
|
||||
data class MibBeneficiary(
|
||||
val benefNo: String,
|
||||
val benefName: String,
|
||||
val benefNickName: String,
|
||||
val benefAccount: String,
|
||||
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
|
||||
val bankColor: String,
|
||||
val benefBankName: String,
|
||||
val bankCode: String,
|
||||
val benefStatus: String,
|
||||
val transferCyDesc: String,
|
||||
val customerImgHash: String?,
|
||||
val benefCategoryId: String, // "0" = uncategorized
|
||||
val profileId: String = "" // MIB profile ID; empty for BML contacts
|
||||
)
|
||||
|
||||
data class MibIpsAccountInfo(
|
||||
val accountName: String,
|
||||
@@ -74,19 +45,6 @@ data class MibIpsAccountInfo(
|
||||
val bankId: String
|
||||
)
|
||||
|
||||
data class Transaction(
|
||||
val id: String,
|
||||
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
|
||||
val description: String,
|
||||
val amount: Double, // negative = debit, positive = credit
|
||||
val currency: String,
|
||||
val counterpartyName: String?,
|
||||
val reference: String?,
|
||||
val accountNumber: String,
|
||||
val accountDisplayName: String,
|
||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||
)
|
||||
|
||||
data class MibFinanceDeal(
|
||||
val dealNo: String,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
|
||||
class MibProfileClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
72
app/src/main/java/sh/sar/basedbank/api/models/BankModels.kt
Normal file
72
app/src/main/java/sh/sar/basedbank/api/models/BankModels.kt
Normal file
@@ -0,0 +1,72 @@
|
||||
package sh.sar.basedbank.api.models
|
||||
|
||||
/**
|
||||
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
||||
* The [bank] field identifies which bank owns this account.
|
||||
*/
|
||||
data class BankAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String,
|
||||
val profileImageHash: String?,
|
||||
val loginTag: String = "",
|
||||
val profileId: String = "", // profile ID used by the bank; empty if not applicable
|
||||
val internalId: String = "" // bank-internal UUID or ID; empty if not applicable
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified contact/beneficiary model used across all banks.
|
||||
* Each bank may interpret fields differently; see per-bank notes below.
|
||||
*/
|
||||
data class BankContact(
|
||||
val benefNo: String,
|
||||
val benefName: String,
|
||||
val benefNickName: String,
|
||||
val benefAccount: String,
|
||||
val benefType: String, // MIB: L=Local, I=Internal, S=Swift; BML: "I"; Fahipay: "FAHIPAY"
|
||||
val bankColor: String,
|
||||
val benefBankName: String,
|
||||
val bankCode: String,
|
||||
val benefStatus: String,
|
||||
val transferCyDesc: String,
|
||||
val customerImgHash: String?,
|
||||
val benefCategoryId: String, // MIB: numeric category ID or "0"; BML: "BML"; Fahipay: "FAHIPAY"
|
||||
val profileId: String = "" // owning profile ID; empty where not applicable
|
||||
)
|
||||
|
||||
/**
|
||||
* Contact category (group) used across MIB and Fahipay.
|
||||
*/
|
||||
data class BankContactCategory(
|
||||
val id: String,
|
||||
val categoryName: String,
|
||||
val numBenef: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified transaction model used across all banks.
|
||||
* [source] identifies the originating bank/account type.
|
||||
*/
|
||||
data class BankTransaction(
|
||||
val id: String,
|
||||
val date: String, // "YYYY-MM-DD HH:mm:ss" (MIB/BML normalised) or ISO8601
|
||||
val description: String,
|
||||
val amount: Double, // negative = debit, positive = credit
|
||||
val currency: String,
|
||||
val counterpartyName: String?,
|
||||
val reference: String?,
|
||||
val accountNumber: String,
|
||||
val accountDisplayName: String,
|
||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||
)
|
||||
@@ -9,8 +9,8 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
@@ -21,13 +21,13 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryAdapter(
|
||||
private val account: MibAccount,
|
||||
private val account: BankAccount,
|
||||
private val display: AccountHistoryDisplay
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
data class Trx(val transaction: BankTransaction) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
@@ -36,7 +36,18 @@ class AccountHistoryAdapter(
|
||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyItemChanged(0) // refresh header card
|
||||
// refresh all transaction rows
|
||||
for (i in displayItems.indices) {
|
||||
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||
imageCache[counterpartyName] = bitmap
|
||||
@@ -73,7 +84,7 @@ class AccountHistoryAdapter(
|
||||
* Display the given (already sorted + filtered) list with date group headers.
|
||||
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
||||
*/
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
fun setTransactions(transactions: List<BankTransaction>) {
|
||||
_showLoadingFooter = false
|
||||
displayItems.clear()
|
||||
lastInsertedDateKey = ""
|
||||
@@ -94,7 +105,7 @@ class AccountHistoryAdapter(
|
||||
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
||||
* notifications, so the RecyclerView doesn't reset scroll position.
|
||||
*/
|
||||
fun appendTransactions(newTransactions: List<Transaction>) {
|
||||
fun appendTransactions(newTransactions: List<BankTransaction>) {
|
||||
if (newTransactions.isEmpty()) return
|
||||
if (_showLoadingFooter) {
|
||||
val pos = itemCount - 1
|
||||
@@ -154,10 +165,10 @@ class AccountHistoryAdapter(
|
||||
b.tvHeaderAccountNumber.text = d.number
|
||||
b.tvHeaderPillBank.text = d.bankPill
|
||||
b.tvHeaderPillType.text = d.typeLabel
|
||||
b.tvHeaderAvailable.text = d.availableBalance
|
||||
b.tvHeaderBalance.text = d.workingBalance
|
||||
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||
if (d.blockedBalance != null) {
|
||||
b.tvHeaderBlocked.text = d.blockedBalance
|
||||
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
|
||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
@@ -173,7 +184,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
fun bind(trx: BankTransaction) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -211,17 +222,22 @@ class AccountHistoryAdapter(
|
||||
|
||||
b.tvDate.text = formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
if (hideAmounts) {
|
||||
b.tvAmount.text = "${trx.currency} ••••••"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||
} else {
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
}
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
private fun showDetail(trx: BankTransaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
@@ -282,6 +298,11 @@ class AccountHistoryAdapter(
|
||||
return FULL_DATE_FMT.format(date)
|
||||
}
|
||||
|
||||
fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
fun sourceColor(source: String) = when (source) {
|
||||
"MIB" -> "#FE860E"
|
||||
"BML", "BML_CARD" -> "#0066A1"
|
||||
|
||||
@@ -22,9 +22,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||
import sh.sar.basedbank.util.AccountHistoryParser
|
||||
@@ -39,10 +39,10 @@ class AccountHistoryFragment : Fragment() {
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private lateinit var adapter: AccountHistoryAdapter
|
||||
private lateinit var account: MibAccount
|
||||
private lateinit var account: BankAccount
|
||||
private lateinit var fetcher: HistoryFetcher
|
||||
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private val allTransactions = mutableListOf<BankTransaction>()
|
||||
private var searchQuery = ""
|
||||
private var firstPageDone = false
|
||||
private val pendingImageNames = mutableSetOf<String>()
|
||||
@@ -53,7 +53,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
|
||||
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
|
||||
fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
||||
}
|
||||
@@ -77,6 +77,8 @@ class AccountHistoryFragment : Fragment() {
|
||||
adapter.onTransferClick = { acc ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||
}
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
@@ -16,32 +16,39 @@ import sh.sar.basedbank.util.AccountListDisplay
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<MibAccount>,
|
||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
||||
accounts: List<BankAccount>,
|
||||
private val onAccountClick: (BankAccount) -> Unit = {}
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
private sealed class Item {
|
||||
data class SectionTitle(val label: String) : Item()
|
||||
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||
data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||
data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||
}
|
||||
|
||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||
|
||||
fun updateAccounts(accounts: List<MibAccount>) {
|
||||
fun updateAccounts(accounts: List<BankAccount>) {
|
||||
items.clear()
|
||||
items.addAll(buildItems(accounts))
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
|
||||
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||
val nonCards = displayed.filter { !it.second.isCard }
|
||||
val cards = displayed.filter { it.second.isCard }
|
||||
|
||||
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
|
||||
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
|
||||
for ((acc, display) in nonCards) {
|
||||
val title = sectionTitle(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||
@@ -57,7 +64,7 @@ class AccountsAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(account: MibAccount): String {
|
||||
private fun sectionTitle(account: BankAccount): String {
|
||||
val bankName = when (account.bank) {
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
@@ -105,11 +112,11 @@ class AccountsAdapter(
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.tvAccountName.text = display.name
|
||||
binding.tvAccountNumber.text = display.number
|
||||
binding.tvAccountType.text = display.typeLabel
|
||||
binding.tvBalance.text = display.balance
|
||||
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
@@ -121,13 +128,13 @@ class AccountsAdapter(
|
||||
|
||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||
binding.tvCardName.text = display.name
|
||||
binding.tvCardNumber.text = display.number
|
||||
binding.tvCardProduct.text = display.typeLabel
|
||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||
binding.tvCardBalance.text = display.balance
|
||||
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||
if (display.statusLabel != null) {
|
||||
binding.tvCardStatus.text = display.statusLabel
|
||||
binding.tvCardStatus.visibility = View.VISIBLE
|
||||
@@ -146,6 +153,11 @@ class AccountsAdapter(
|
||||
private const val TYPE_ACCOUNT = 1
|
||||
private const val TYPE_CARD = 2
|
||||
|
||||
fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||
|
||||
@@ -44,6 +44,7 @@ class AccountsFragment : Fragment() {
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class ActivitiesAdapter(
|
||||
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
|
||||
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
for (entry in entries) {
|
||||
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||
if (dateKey != lastDateKey) {
|
||||
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||
lastDateKey = dateKey
|
||||
}
|
||||
displayItems.add(Item.ReceiptItem(entry))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemCount() = displayItems.size
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == TYPE_DATE_HEADER)
|
||||
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
else
|
||||
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||
}
|
||||
}
|
||||
|
||||
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||
}
|
||||
|
||||
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(entry: ReceiptStore.Entry) {
|
||||
val d = entry.data
|
||||
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
b.fvAvatar.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||
}
|
||||
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||
b.tvInitial.text = initial
|
||||
|
||||
b.tvCounterparty.text = d.toLabel
|
||||
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||
b.tvDescription.text = buildString {
|
||||
append(d.fromLabel)
|
||||
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||
}
|
||||
b.tvDate.text = formatTime(entry.savedAt)
|
||||
|
||||
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||
|
||||
b.root.setOnClickListener { onItemClick(entry) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDateHeader(millis: Long): String {
|
||||
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||
return sdf.format(Date(millis))
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long): String {
|
||||
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||
return sdf.format(Date(millis))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_DATE_HEADER = 0
|
||||
private const val TYPE_RECEIPT = 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,9 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountValidation
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
@@ -64,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var selectedImageBase64: String = ""
|
||||
private var selectedCategoryId: String = "0"
|
||||
private var categories: List<MibBeneficiaryCategory> = emptyList()
|
||||
private var categories: List<BankContactCategory> = emptyList()
|
||||
|
||||
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri ?: return@registerForActivityResult
|
||||
@@ -98,10 +99,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
val store = CredentialStore(requireContext())
|
||||
for ((loginId, _) in app.bmlSessions) {
|
||||
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
|
||||
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
|
||||
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
|
||||
for ((loginId, profiles) in app.bmlProfilesMap) {
|
||||
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
for (profile in profiles) {
|
||||
if (app.bmlSessions.containsKey(profile.profileId)) {
|
||||
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -241,14 +245,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
|
||||
// 1) Try BML validate
|
||||
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
if (validated != null) return validated
|
||||
|
||||
// 2) Try BML MIB verify
|
||||
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
if (mibVerified != null) return mibVerified
|
||||
|
||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||
@@ -292,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||
val bmlSess = app.anyBmlSession() ?: return null
|
||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
||||
@@ -418,15 +420,14 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||
val lookup = bmlLookup ?: return false
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val account = lookup.account
|
||||
return when {
|
||||
account.matches(Regex("^7\\d{12}$")) ->
|
||||
// BML account → IAT
|
||||
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
|
||||
BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
|
||||
account.matches(Regex("^9\\d{16}$")) ->
|
||||
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
||||
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
|
||||
BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
|
||||
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
||||
else -> false
|
||||
}
|
||||
@@ -486,7 +487,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
if (dest.isBml) {
|
||||
val loginId = dest.bmlLoginId ?: return@launch
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
||||
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
|
||||
val existing = viewModel.contacts.value ?: emptyList()
|
||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||
viewModel.contacts.postValue(merged)
|
||||
|
||||
@@ -23,7 +23,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
@@ -153,7 +153,7 @@ class ContactsFragment : Fragment() {
|
||||
}.also { it.attach() }
|
||||
}
|
||||
|
||||
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) {
|
||||
private fun rebuildPager(cats: List<BankContactCategory>) {
|
||||
val pages = buildList {
|
||||
add(TabPage(null, getString(R.string.contacts_tab_all)))
|
||||
cats.forEach { add(TabPage(it.id, it.categoryName)) }
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||
@@ -32,6 +32,11 @@ class DashboardFragment : Fragment() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||
updatePendingFinances(viewModel.financing.value ?: emptyList())
|
||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||
}
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||
@@ -63,7 +68,13 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBalances(accounts: List<MibAccount>) {
|
||||
private fun updateBalances(accounts: List<BankAccount>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
if (hide) {
|
||||
binding.tvMvrBalance.text = "MVR ••••••"
|
||||
binding.tvUsdBalance.text = "USD ••••••"
|
||||
return
|
||||
}
|
||||
val mvrTotal = accounts
|
||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
@@ -76,31 +87,40 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
binding.containerForeignLimits.removeAllViews()
|
||||
for (entry in entries) {
|
||||
for (limit in entry.limits) {
|
||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
||||
card.tvLimitType.text = limit.type
|
||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||
if (hide) {
|
||||
card.tvLimitGeneral.text = "USD ••••••"
|
||||
card.tvLimitMedical.text = "USD ••••••"
|
||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
||||
card.tvLimitEcom.text = "USD ••••••"
|
||||
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
||||
} else {
|
||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||
}
|
||||
binding.containerForeignLimits.addView(card.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
||||
val total = deals.sumOf { it.outstandingAmount }
|
||||
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -16,6 +16,14 @@ import java.util.Locale
|
||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
||||
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private val expandedPositions = mutableSetOf<Int>()
|
||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||
minimumFractionDigits = 2
|
||||
@@ -52,19 +60,20 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
val currency = deal.currency
|
||||
val hide = hideAmounts
|
||||
|
||||
binding.tvProductName.text = deal.productDesc
|
||||
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
||||
binding.tvStatus.text = deal.statusDesc
|
||||
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||
|
||||
// Progress bar
|
||||
val progress = if (deal.dealAmount > 0)
|
||||
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
||||
else 0
|
||||
binding.progressBar.progress = progress
|
||||
binding.progressBar.progress = if (hide) 0 else progress
|
||||
|
||||
// Completion estimate
|
||||
binding.tvCompletion.text = completionText(deal, ctx)
|
||||
@@ -76,14 +85,14 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
|
||||
if (expanded) {
|
||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
||||
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
||||
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
||||
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||
|
||||
if (deal.overdueAmount > 0) {
|
||||
binding.rowOverdue.visibility = View.VISIBLE
|
||||
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||
} else {
|
||||
binding.rowOverdue.visibility = View.GONE
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class FinancingFragment : Fragment() {
|
||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -17,7 +17,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -33,16 +36,22 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.AuthExpiredException
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayContactsClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.ActivityHomeBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
@@ -108,6 +117,12 @@ class HomeActivity : AppCompatActivity() {
|
||||
binding.drawerLayout.addDrawerListener(toggle)
|
||||
toggle.syncState()
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.navigationView) { v, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.updatePadding(top = bars.top, bottom = bars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||
val frag = when (item.itemId) {
|
||||
@@ -115,7 +130,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()
|
||||
@@ -177,6 +194,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
autoRefresh(store)
|
||||
}
|
||||
|
||||
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
|
||||
|
||||
// Show dashboard on first create
|
||||
if (savedInstanceState == null) {
|
||||
show(DashboardFragment())
|
||||
@@ -248,9 +267,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
|
||||
@@ -259,14 +291,16 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||
val dest = fragment ?: when (itemId) {
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> TransferFragment()
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> TransferFragment()
|
||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||
R.id.nav_activities -> ActivitiesFragment()
|
||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||
R.id.nav_finances -> FinancingFragment()
|
||||
R.id.nav_otp -> OtpFragment()
|
||||
R.id.nav_settings -> SettingsFragment()
|
||||
R.id.nav_finances -> FinancingFragment()
|
||||
R.id.nav_otp -> OtpFragment()
|
||||
R.id.nav_settings -> SettingsFragment()
|
||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||
}
|
||||
show(dest)
|
||||
@@ -383,6 +417,11 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
||||
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
|
||||
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
||||
eyeItem?.isVisible = eyeEnabled
|
||||
val hidden = viewModel.hideAmounts.value ?: false
|
||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -391,6 +430,13 @@ class HomeActivity : AppCompatActivity() {
|
||||
lock()
|
||||
return true
|
||||
}
|
||||
if (item.itemId == R.id.action_hide_amounts) {
|
||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.value = newHidden
|
||||
getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
|
||||
invalidateOptionsMenu()
|
||||
return true
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
@@ -445,33 +491,85 @@ class HomeActivity : AppCompatActivity() {
|
||||
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
||||
loginId to async(Dispatchers.IO) {
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val loginTag = "bml_$loginId"
|
||||
val savedToken = store.loadBmlSession(loginId)
|
||||
val app = application as BasedBankApp
|
||||
val savedProfiles = store.loadBmlProfiles(loginId)
|
||||
val allAccounts = mutableListOf<BankAccount>()
|
||||
var anyExpired = savedProfiles.isEmpty()
|
||||
|
||||
if (savedToken != null) {
|
||||
try {
|
||||
val session = BmlSession(savedToken.first, savedToken.second)
|
||||
val accounts = bmlFlow.fetchAccounts(session, loginTag)
|
||||
val app = application as BasedBankApp
|
||||
app.bmlSessions[loginId] = session
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
return@async Pair(session, accounts)
|
||||
} catch (_: AuthExpiredException) {
|
||||
} catch (_: Exception) {
|
||||
// Try each saved profile's cached session
|
||||
for (profile in savedProfiles) {
|
||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||
if (saved != null) {
|
||||
try {
|
||||
val session = BmlSession(saved.first, saved.second)
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||
app.bmlSessions[profile.profileId] = session
|
||||
allAccounts += accounts
|
||||
} catch (_: AuthExpiredException) { anyExpired = true
|
||||
} catch (_: Exception) { anyExpired = true }
|
||||
} else {
|
||||
anyExpired = true
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
|
||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
||||
val app = application as BasedBankApp
|
||||
app.bmlSessions[loginId] = session
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
Pair(session, accounts)
|
||||
} catch (_: Exception) {
|
||||
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
|
||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||
|
||||
// Also try legacy single-profile session token (pre-multi-profile installs)
|
||||
if (savedProfiles.isEmpty()) {
|
||||
val legacyToken = store.loadBmlSession(loginId)
|
||||
if (legacyToken != null) {
|
||||
try {
|
||||
val session = BmlSession(legacyToken.first, legacyToken.second)
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
|
||||
app.bmlSessions[loginId] = session
|
||||
allAccounts += accounts
|
||||
anyExpired = false
|
||||
} catch (_: AuthExpiredException) { anyExpired = true
|
||||
} catch (_: Exception) { anyExpired = true }
|
||||
}
|
||||
}
|
||||
|
||||
if (anyExpired || allAccounts.isEmpty()) {
|
||||
// Re-authenticate to refresh personal profile sessions
|
||||
try {
|
||||
val flow = app.bmlFlowFor(loginId)
|
||||
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
|
||||
store.saveBmlProfiles(loginId, profiles)
|
||||
app.bmlProfilesMap[loginId] = profiles
|
||||
|
||||
for (profile in profiles) {
|
||||
if (profile.profileType == "business") {
|
||||
// Can't activate business profiles without user OTP — use cached
|
||||
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
if (allAccounts.none { it.profileId == profile.profileId })
|
||||
allAccounts += cached
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val result = flow.activateProfile(profile, loginTag)
|
||||
if (result is BmlActivationResult.Success) {
|
||||
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||
app.bmlSessions[profile.profileId] = result.session
|
||||
allAccounts.removeAll { it.profileId == profile.profileId }
|
||||
allAccounts += result.accounts
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
if (allAccounts.none { it.profileId == profile.profileId }) {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
if (allAccounts.isEmpty())
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
}
|
||||
}
|
||||
|
||||
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
|
||||
allAccounts as List<BankAccount>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,10 +585,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (savedSession != null) {
|
||||
try {
|
||||
val session = FahipaySession(savedSession.first, savedSession.second)
|
||||
fahipayFlow.setSessionCookie(session.sessionCookie)
|
||||
val balance = fahipayFlow.fetchBalance(session)
|
||||
val profile = fahipayFlow.fetchProfile(session)
|
||||
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
|
||||
val balance = FahipayAccountClient().fetchBalance(session)
|
||||
val profile = FahipayAccountClient().fetchProfile(session)
|
||||
val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
|
||||
val app = application as BasedBankApp
|
||||
app.fahipaySessions[loginId] = session
|
||||
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||
@@ -507,9 +604,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
|
||||
val session = FahipaySession(authId, cookieValue)
|
||||
store.saveFahipaySession(loginId, authId, cookieValue)
|
||||
val profile = fahipayFlow.fetchProfile(session)
|
||||
val balance = fahipayFlow.fetchBalance(session)
|
||||
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
|
||||
val profile = FahipayAccountClient().fetchProfile(session)
|
||||
val balance = FahipayAccountClient().fetchBalance(session)
|
||||
val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
|
||||
val app = application as BasedBankApp
|
||||
app.fahipaySessions[loginId] = session
|
||||
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||
@@ -522,8 +619,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,14 +638,23 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters MIB accounts whose profileId the user has hidden in settings. */
|
||||
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
|
||||
/** Filters accounts whose profileId the user has hidden in settings. */
|
||||
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
return filter { acc ->
|
||||
if (acc.bank != "MIB") return@filter true
|
||||
val loginId = acc.loginTag.removePrefix("mib_")
|
||||
val hidden = store.getHiddenMibProfileIds(loginId)
|
||||
hidden.isEmpty() || acc.profileId !in hidden
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,11 +672,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun refreshBmlLimits(session: BmlSession) {
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val (userName, limits) = withContext(Dispatchers.IO) {
|
||||
Pair(bmlFlow.fetchUserInfo(session)?.fullName ?: "", bmlFlow.fetchForeignLimits(session))
|
||||
Pair(BmlAccountClient().fetchUserInfo(session)?.fullName ?: "", BmlForeignLimitsClient().fetchForeignLimits(session))
|
||||
}
|
||||
val existing = viewModel.bmlLimits.value?.toMutableList() ?: mutableListOf()
|
||||
val idx = existing.indexOfFirst { it.userName == userName }
|
||||
@@ -585,11 +689,13 @@ 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) ->
|
||||
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
|
||||
store.getBmlLoginIds().flatMap { loginId ->
|
||||
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
|
||||
val contacts = BmlContactsClient().fetchContacts(session, loginId)
|
||||
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
||||
contacts
|
||||
}
|
||||
@@ -632,13 +738,11 @@ class HomeActivity : AppCompatActivity() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val groups = withContext(Dispatchers.IO) {
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
flow.fetchContacts(session)
|
||||
FahipayContactsClient().fetchContacts(session)
|
||||
}
|
||||
if (groups.isEmpty()) return@launch
|
||||
val contacts = groups.flatMap { it.contacts }
|
||||
val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) }
|
||||
val categories = groups.map { BankContactCategory(it.categoryId, it.label, it.contacts.size) }
|
||||
ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
|
||||
val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
|
||||
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
|
||||
@@ -651,11 +755,11 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun mergeContacts(
|
||||
mib: List<MibBeneficiary>,
|
||||
bml: List<MibBeneficiary>
|
||||
): List<MibBeneficiary> {
|
||||
mib: List<BankContact>,
|
||||
bml: List<BankContact>
|
||||
): List<BankContact> {
|
||||
val seen = mutableSetOf<String>()
|
||||
val result = mutableListOf<MibBeneficiary>()
|
||||
val result = mutableListOf<BankContact>()
|
||||
for (c in mib) if (seen.add(c.benefNo)) result.add(c)
|
||||
for (c in bml) if (seen.add(c.benefNo)) result.add(c)
|
||||
return result
|
||||
@@ -670,8 +774,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
|
||||
val seenContacts = mutableSetOf<String>()
|
||||
val seenCategories = mutableSetOf<String>()
|
||||
val contacts = mutableListOf<MibBeneficiary>()
|
||||
val categories = mutableListOf<MibBeneficiaryCategory>()
|
||||
val contacts = mutableListOf<BankContact>()
|
||||
val categories = mutableListOf<BankContactCategory>()
|
||||
for (profile in profiles) {
|
||||
try {
|
||||
flow.switchProfile(session, profile)
|
||||
@@ -699,7 +803,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshBalances(src: MibAccount) {
|
||||
fun refreshBalances(src: BankAccount) {
|
||||
val app = application as BasedBankApp
|
||||
lifecycleScope.launch {
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
@@ -707,12 +811,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.fahipaySessionFor(src) ?: return@withContext null
|
||||
try {
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(sess.sessionCookie)
|
||||
val balance = flow.fetchBalance(sess)
|
||||
val profile = flow.fetchProfile(sess)
|
||||
val balance = FahipayAccountClient().fetchBalance(sess)
|
||||
val profile = FahipayAccountClient().fetchProfile(sess)
|
||||
val loginTag = "fahipay_${profile.profileId}"
|
||||
val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
|
||||
val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
|
||||
val loginId = src.loginTag.removePrefix("fahipay_")
|
||||
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
|
||||
@@ -726,7 +828,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||
try {
|
||||
val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
|
||||
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
|
||||
app.bmlAccounts = otherBml + accounts
|
||||
|
||||
@@ -3,17 +3,19 @@ package sh.sar.basedbank.ui.home
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
val accounts = MutableLiveData<List<MibAccount>>(emptyList())
|
||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList())
|
||||
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList())
|
||||
val contacts = MutableLiveData<List<BankContact>>(emptyList())
|
||||
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
|
||||
|
||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||
|
||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||
@@ -94,7 +95,7 @@ class OtpFragment : Fragment() {
|
||||
val session = app.mibSessions[loginId] ?: continue
|
||||
val flow = app.mibFlowFor(loginId)
|
||||
val profile = withContext(Dispatchers.IO) {
|
||||
try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||
}
|
||||
if (profile != null) {
|
||||
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
|
||||
@@ -114,7 +115,7 @@ class OtpFragment : Fragment() {
|
||||
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
|
||||
val session = app.bmlSessions[loginId] ?: continue
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||
try { BmlAccountClient().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||
}
|
||||
if (info != null) {
|
||||
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
|
||||
|
||||
420
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
420
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
@@ -0,0 +1,420 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class PayMvQrFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPayMvQrBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var selectedAccount: BankAccount? = null
|
||||
private var generatedBitmap: Bitmap? = null
|
||||
private var generateJob: Job? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose
|
||||
))
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val basePaddingBottom = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
|
||||
insets
|
||||
}
|
||||
setupDropdown()
|
||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||
binding.btnShare.isEnabled = false
|
||||
binding.btnSave.isEnabled = false
|
||||
binding.btnShare.setOnClickListener { shareQr() }
|
||||
binding.btnSave.setOnClickListener { saveQr() }
|
||||
binding.btnScanQr.setOnClickListener {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDropdown() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
binding.actvAccount.setAdapter(adapter)
|
||||
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
|
||||
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
||||
selectedAccount = picked
|
||||
scheduleGenerate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleGenerate() {
|
||||
generateJob?.cancel()
|
||||
generateJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||
delay(300)
|
||||
generateQr()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun generateQr() {
|
||||
val account = selectedAccount ?: return
|
||||
val acquirer = when (account.bank) {
|
||||
"BML" -> "MALBMVMV"
|
||||
"MIB" -> "MADVMVMV"
|
||||
"FAHIPAY" -> "FAHIMVMV"
|
||||
else -> "MADVMVMV"
|
||||
}
|
||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||
?.replace(",", "")
|
||||
?.toDoubleOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?.let { "%.2f".format(it) }
|
||||
|
||||
val ctx = requireContext()
|
||||
val bmp = withContext(Dispatchers.Default) {
|
||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||
renderQrCard(ctx, account, payload, amountFormatted)
|
||||
}
|
||||
if (_binding == null) return
|
||||
generatedBitmap = bmp
|
||||
binding.tvQrPlaceholder.visibility = View.GONE
|
||||
binding.ivQrCard.setImageBitmap(bmp)
|
||||
binding.ivQrCard.visibility = View.VISIBLE
|
||||
binding.btnShare.isEnabled = true
|
||||
binding.btnSave.isEnabled = true
|
||||
}
|
||||
|
||||
// ── EMV MPQR payload ──────────────────────────────────────────────────────
|
||||
|
||||
private fun buildQrPayload(
|
||||
accountNumber: String,
|
||||
accountName: String,
|
||||
acquirer: String,
|
||||
amountStr: String?
|
||||
): String {
|
||||
fun tlv(tag: String, value: String): String {
|
||||
val len = value.length
|
||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||
}
|
||||
val format = tlv("00", "01")
|
||||
val poi = tlv("01", "11")
|
||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||
val sub01 = tlv("01", acquirer)
|
||||
val sub03 = tlv("03", accountNumber)
|
||||
val sub10 = tlv("10", "IPAY")
|
||||
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||
val currency = tlv("53", "462")
|
||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||
val country = tlv("58", "MV")
|
||||
val name = tlv("59", accountName.take(25))
|
||||
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
||||
return prefix + crc16(prefix)
|
||||
}
|
||||
|
||||
private fun crc16(data: String): String {
|
||||
var crc = 0xFFFF
|
||||
for (c in data) {
|
||||
crc = crc xor ((c.code and 0xFF) shl 8)
|
||||
repeat(8) {
|
||||
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
|
||||
else (crc shl 1) and 0xFFFF
|
||||
}
|
||||
}
|
||||
return crc.toString(16).uppercase().padStart(4, '0')
|
||||
}
|
||||
|
||||
// ── QR card rendering ────────────────────────────────────────────────────
|
||||
|
||||
private fun renderQrCard(
|
||||
ctx: Context,
|
||||
account: BankAccount,
|
||||
qrPayload: String,
|
||||
amountStr: String?
|
||||
): Bitmap {
|
||||
val W = 900
|
||||
val H = 1080
|
||||
val outerCorner = 48f
|
||||
val boxBlue = Color.parseColor("#2272B7")
|
||||
val footerBlue = Color.parseColor("#1A5799")
|
||||
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||
|
||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Clip to outer rounded card shape
|
||||
val outerPath = Path()
|
||||
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||
canvas.clipPath(outerPath)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
// --- Bank logo top-left ---
|
||||
val logoRes = when (account.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"MIB" -> R.drawable.mib_faisanet_logo
|
||||
else -> R.drawable.fahipay_logo_long
|
||||
}
|
||||
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||
val maxW = 180f; val maxH = 76f
|
||||
val scale = minOf(maxW / nW, maxH / nH)
|
||||
val lW = (nW * scale).toInt()
|
||||
val lH = (nH * scale).toInt()
|
||||
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||
d.draw(canvas)
|
||||
}
|
||||
|
||||
// --- "PayMV QR" top-right ---
|
||||
paint.color = Color.parseColor("#1A1A2E")
|
||||
paint.textSize = 36f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||
|
||||
// --- Blue rounded box ---
|
||||
paint.color = boxBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||
|
||||
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||
paint.color = Color.WHITE
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val nameText = account.accountBriefName.uppercase()
|
||||
paint.textSize = 36f
|
||||
val maxNameW = boxR - boxL - 48f
|
||||
if (paint.measureText(nameText) > maxNameW) {
|
||||
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||
}
|
||||
val nameBaseline = boxT + 68f
|
||||
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||
|
||||
// Optional amount below name
|
||||
val qrTopY: Float
|
||||
if (!amountStr.isNullOrBlank()) {
|
||||
paint.textSize = 28f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||
val amtBaseline = nameBaseline + 42f
|
||||
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||
qrTopY = amtBaseline + 20f
|
||||
} else {
|
||||
qrTopY = nameBaseline + 26f
|
||||
}
|
||||
|
||||
// QR code — white modules on the same blue as the box background
|
||||
val availH = boxB - qrTopY - 24f
|
||||
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||
try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||
val pixels = IntArray(qrPx * qrPx)
|
||||
for (y in 0 until qrPx) {
|
||||
for (x in 0 until qrPx) {
|
||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||
}
|
||||
}
|
||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||
qrBm.recycle()
|
||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||
|
||||
// --- Dark blue footer ---
|
||||
paint.color = footerBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = 32f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||
|
||||
return bm
|
||||
}
|
||||
|
||||
// ── Share / Save ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun shareQr() {
|
||||
val bmp = generatedBitmap ?: return
|
||||
val account = selectedAccount ?: return
|
||||
lifecycleScope.launch {
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dir = File(requireContext().cacheDir, "qr")
|
||||
dir.mkdirs()
|
||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
FileProvider.getUriForFile(
|
||||
requireContext(),
|
||||
"${requireContext().packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (uri == null || _binding == null) return@launch
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "image/png"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveQr() {
|
||||
val bmp = generatedBitmap ?: return
|
||||
val account = selectedAccount ?: return
|
||||
lifecycleScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||
val filename = "${safeName}_PayMV_QR.png"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
|
||||
}
|
||||
val uri = requireContext().contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
|
||||
) ?: return@withContext false
|
||||
requireContext().contentResolver.openOutputStream(uri)?.use {
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||
dir.mkdirs()
|
||||
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.pay_mv_qr)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||
|
||||
private inner class QrAccountAdapter(
|
||||
private val context: Context,
|
||||
private val accounts: List<BankAccount>
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
fun getAccount(position: Int): BankAccount? = accounts.getOrNull(position)
|
||||
|
||||
override fun getCount() = accounts.size
|
||||
override fun getItem(position: Int) = accounts.getOrNull(position)
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getDropDownView(position, convertView, parent)
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val acc = accounts[position]
|
||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||
convertView.tag as ItemAccountDropdownBinding
|
||||
} else {
|
||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
.also { it.root.tag = it }
|
||||
}
|
||||
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||
b.tvDropdownBalance.text = ""
|
||||
b.root.alpha = 1f
|
||||
return b.root
|
||||
}
|
||||
|
||||
override fun getFilter() = object : Filter() {
|
||||
override fun performFiltering(c: CharSequence?) =
|
||||
FilterResults().apply { values = accounts; count = accounts.size }
|
||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||
override fun convertResultToString(r: Any?) =
|
||||
(r as? BankAccount)?.let {
|
||||
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||
"$prefix${it.accountBriefName}"
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,16 @@ 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 androidx.fragment.app.activityViewModels
|
||||
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
|
||||
@@ -26,11 +28,21 @@ import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
|
||||
class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsLoginsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||
@@ -78,24 +90,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,13 +100,15 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
showLoginDetails(
|
||||
title = getString(R.string.fahipay_name),
|
||||
details = buildString {
|
||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
|
||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
||||
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
|
||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
|
||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
|
||||
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
|
||||
}.trim(),
|
||||
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||
)
|
||||
@@ -195,8 +194,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 +218,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 +246,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 +263,349 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBmlLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.BmlUserProfile?,
|
||||
bmlProfiles: List<BmlProfile>
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hidden = store.getHiddenBmlProfileIds(loginId).toMutableSet()
|
||||
// Business profiles with no saved session were skipped during login — ensure they start hidden
|
||||
val needsActivation = bmlProfiles
|
||||
.filter { it.profileType == "business" && store.loadBmlProfileSession(it.profileId) == null }
|
||||
.map { it.profileId }
|
||||
.toMutableSet()
|
||||
for (id in needsActivation) {
|
||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||
}
|
||||
val originalHidden = hidden.toSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
listOfNotNull(
|
||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
||||
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: ${if (hide) masked else it}" },
|
||||
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (bmlProfiles.isNotEmpty()) {
|
||||
if (profile != null) {
|
||||
container.addView(View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
setBackgroundColor(0x1F000000)
|
||||
})
|
||||
}
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = getString(R.string.login_detail_profiles)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (8 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val toggleRows = bmlProfiles.map { p ->
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = p.name
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
})
|
||||
if (p.type.isNotBlank()) {
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = p.type
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
row.addView(textCol)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
}
|
||||
|
||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||
toggleRows.forEach { (_, toggle) ->
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.bml_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.save, null)
|
||||
.setNeutralButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) }
|
||||
}
|
||||
.show()
|
||||
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
updateToggleStates(saveBtn)
|
||||
|
||||
toggleRows.forEach { (p, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked && p.profileId in needsActivation) {
|
||||
toggle.isChecked = false // revert — enabling requires OTP
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = activateBmlBusinessProfile(store, loginId, p)
|
||||
if (success) {
|
||||
needsActivation.remove(p.profileId)
|
||||
toggle.isChecked = true // listener re-fires, removes from hidden
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun activateBmlBusinessProfile(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: BmlProfile
|
||||
): Boolean {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: run {
|
||||
showSimpleError("Credentials not found — please log out and log in again")
|
||||
return false
|
||||
}
|
||||
val progressDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage("Connecting to BML\u2026")
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
val flow = BmlLoginFlow()
|
||||
val loginTag = "bml_$loginId"
|
||||
val activationResult = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
flow.login(creds.username, creds.password, creds.otpSeed)
|
||||
flow.activateProfile(profile, loginTag)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
showSimpleError(e.message ?: "Authentication failed")
|
||||
return false
|
||||
}
|
||||
progressDialog.dismiss()
|
||||
return when (activationResult) {
|
||||
is BmlActivationResult.Success -> {
|
||||
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
|
||||
true
|
||||
}
|
||||
is BmlActivationResult.NeedsBusinessOtp ->
|
||||
continueBmlBusinessOtpFlow(store, loginId, profile, flow, loginTag, activationResult.channels)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun continueBmlBusinessOtpFlow(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: BmlProfile,
|
||||
flow: BmlLoginFlow,
|
||||
loginTag: String,
|
||||
channels: List<BmlOtpChannel>
|
||||
): Boolean {
|
||||
val selectedChannel = showBmlChannelSelectionDialog(profile.name, channels) ?: return false
|
||||
val channelObj = channels.first { it.channel == selectedChannel }
|
||||
val sendProgress = MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage("Sending OTP\u2026")
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
try {
|
||||
withContext(Dispatchers.IO) { flow.requestBusinessOtp(selectedChannel) }
|
||||
} catch (e: Exception) {
|
||||
sendProgress.dismiss()
|
||||
showSimpleError(e.message ?: "Failed to send OTP")
|
||||
return false
|
||||
}
|
||||
sendProgress.dismiss()
|
||||
var otpError: String? = null
|
||||
while (true) {
|
||||
val code = showBmlOtpInputDialog(profile.name, channelObj, otpError) ?: return false
|
||||
val verifyProgress = MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage("Verifying\u2026")
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
try {
|
||||
val (session, _) = withContext(Dispatchers.IO) {
|
||||
flow.submitBusinessOtp(selectedChannel, code, profile, loginTag)
|
||||
}
|
||||
verifyProgress.dismiss()
|
||||
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
verifyProgress.dismiss()
|
||||
if (e.message?.contains("Invalid OTP") == true) {
|
||||
otpError = e.message
|
||||
} else {
|
||||
showSimpleError(e.message ?: "Verification failed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSimpleError(message: String) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private suspend fun showBmlChannelSelectionDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val vp = (8 * dp).toInt()
|
||||
setPadding(0, vp, 0, vp)
|
||||
}
|
||||
|
||||
for (channel in channels) {
|
||||
val iconRes = when (channel.channel) {
|
||||
"Email" -> R.drawable.ic_channel_email
|
||||
"Mobile" -> R.drawable.ic_channel_sms
|
||||
"WhatsApp" -> R.drawable.ic_channel_whatsapp
|
||||
else -> R.drawable.ic_channel_sms
|
||||
}
|
||||
val iconSize = (24 * dp).toInt()
|
||||
|
||||
val iconView = ImageView(ctx).apply {
|
||||
setImageResource(iconRes)
|
||||
}
|
||||
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = channel.description
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
})
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = channel.masked
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
val hp = (24 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||
setPadding(hp, vp, hp, vp)
|
||||
}
|
||||
row.addView(iconView, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
row.addView(textCol)
|
||||
list.addView(row)
|
||||
}
|
||||
|
||||
val d = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle("Send verification code")
|
||||
.setView(list)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
|
||||
|
||||
// Wire up row clicks after dialog is created so we can dismiss it first
|
||||
val rows = list.run { (0 until childCount).map { getChildAt(it) } }
|
||||
rows.forEachIndexed { i, row ->
|
||||
row.setOnClickListener {
|
||||
d.dismiss()
|
||||
if (cont.isActive) cont.resume(channels[i].channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showBmlOtpInputDialog(
|
||||
profileName: String,
|
||||
channel: BmlOtpChannel,
|
||||
errorMsg: String? = null
|
||||
): String? = suspendCancellableCoroutine { cont ->
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val input = android.widget.EditText(ctx).apply {
|
||||
hint = "Enter OTP"
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||
setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
}
|
||||
val msg = buildString {
|
||||
append(getString(R.string.bml_business_otp_sent, channel.description))
|
||||
append(" (${channel.masked})")
|
||||
if (errorMsg != null) append("\n\n$errorMsg")
|
||||
}
|
||||
val d = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle("Enter verification code")
|
||||
.setMessage(msg)
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.verify, null)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
|
||||
d.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val code = input.text.toString().trim()
|
||||
if (code.length != 6) {
|
||||
input.error = "Enter 6 digits"
|
||||
} else {
|
||||
d.dismiss()
|
||||
if (cont.isActive) cont.resume(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
@@ -290,9 +641,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()
|
||||
|
||||
@@ -79,6 +79,17 @@ class SettingsSecurityFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.resetAutolockTimer()
|
||||
}
|
||||
|
||||
// Hide sensitive information (enables/disables the eye icon in toolbar)
|
||||
val viewModel = (requireActivity() as HomeActivity).let {
|
||||
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
|
||||
}
|
||||
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
|
||||
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
|
||||
if (!isChecked) viewModel.hideAmounts.value = false
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
// Block screenshots
|
||||
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||
|
||||
@@ -9,17 +9,17 @@ import android.view.ViewGroup
|
||||
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
|
||||
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
|
||||
/** Adapter for BankTransaction History — date-grouped, shows account name in secondary line. */
|
||||
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
data class Trx(val transaction: BankTransaction) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||
imageCache[counterpartyName] = bitmap
|
||||
@@ -60,7 +67,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
}
|
||||
|
||||
/** Replace the full sorted transaction list and rebuild date groups. */
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
fun setTransactions(transactions: List<BankTransaction>) {
|
||||
_showLoadingFooter = false
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
@@ -107,7 +114,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
fun bind(trx: BankTransaction) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -134,23 +141,28 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
}
|
||||
b.tvDescription.text = trx.description
|
||||
|
||||
// Show account name in secondary line for Transfer History
|
||||
// Show account name in secondary line for BankTransaction History
|
||||
b.tvCounterparty.text = trx.accountDisplayName
|
||||
b.tvCounterparty.visibility = View.VISIBLE
|
||||
|
||||
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
if (hideAmounts) {
|
||||
b.tvAmount.text = "${trx.currency} ••••••"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||
} else {
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
}
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
private fun showDetail(trx: BankTransaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
|
||||
@@ -36,12 +36,13 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.api.mib.MibLookupException
|
||||
@@ -56,6 +57,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() {
|
||||
@@ -64,11 +66,11 @@ class TransferFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var selectedAccount: MibAccount? = null
|
||||
private var selectedAccount: BankAccount? = null
|
||||
private val session get() = selectedAccount
|
||||
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
|
||||
?: (requireActivity().application as BasedBankApp).anyMibSession()
|
||||
private fun bmlSessionFor(account: MibAccount?) =
|
||||
private fun bmlSessionFor(account: BankAccount?) =
|
||||
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
|
||||
?: (requireActivity().application as BasedBankApp).anyBmlSession()
|
||||
|
||||
@@ -101,8 +103,10 @@ 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 {
|
||||
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||
}
|
||||
|
||||
@@ -121,6 +125,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 +175,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 +185,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() {
|
||||
@@ -218,7 +240,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFromCard(account: MibAccount) {
|
||||
private fun showFromCard(account: BankAccount) {
|
||||
val colorHex = when (account.bank) {
|
||||
"BML" -> "#0066A1"
|
||||
"FAHIPAY" -> "#15BEA7"
|
||||
@@ -263,7 +285,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAmountPrefix(account: MibAccount) {
|
||||
private fun updateAmountPrefix(account: BankAccount) {
|
||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||
}
|
||||
|
||||
@@ -348,10 +370,9 @@ class TransferFragment : Fragment() {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
if (isBmlSource && bmlSess != null) {
|
||||
val inputType = AccountInputParser.detect(accountNumber)
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val bmlResult = try {
|
||||
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) bmlFlow.verifyMibAccount(bmlSess, accountNumber)
|
||||
else bmlFlow.validateAccount(bmlSess, accountNumber)
|
||||
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) BmlValidateClient().verifyMibAccount(bmlSess, accountNumber)
|
||||
else BmlValidateClient().validateAccount(bmlSess, accountNumber)
|
||||
} catch (_: Exception) { null }
|
||||
if (bmlResult != null) {
|
||||
val bankId = when (bmlResult.trnType) {
|
||||
@@ -372,7 +393,7 @@ class TransferFragment : Fragment() {
|
||||
catch (e: MibLookupException) { errorMsg = e.message; null }
|
||||
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
|
||||
} else {
|
||||
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
|
||||
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
|
||||
if (bmlResult != null) {
|
||||
val bankId = when (bmlResult.trnType) {
|
||||
"IAT" -> "MALBMVMV"
|
||||
@@ -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)
|
||||
@@ -709,7 +731,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun doMibTransfer(
|
||||
src: MibAccount,
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destName: String,
|
||||
destDisplay: String,
|
||||
@@ -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,
|
||||
@@ -768,7 +790,7 @@ class TransferFragment : Fragment() {
|
||||
mibReferenceNo = result.trxId,
|
||||
mibTransactionDate = result.date
|
||||
)
|
||||
Triple(true, "Transaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
@@ -778,7 +800,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun doBmlTransfer(
|
||||
src: MibAccount,
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destDisplay: String,
|
||||
amount: Double,
|
||||
@@ -787,8 +809,8 @@ class TransferFragment : Fragment() {
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<MibAccount>,
|
||||
allContacts: List<MibBeneficiary>
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||
@@ -823,10 +845,9 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
// Step 1: initiate
|
||||
val initiated = try {
|
||||
bmlFlow.initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
|
||||
|
||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||
@@ -836,10 +857,10 @@ class TransferFragment : Fragment() {
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
|
||||
return try {
|
||||
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
isMib = false,
|
||||
bank = "BML",
|
||||
amount = "%.2f".format(amount),
|
||||
currency = currency,
|
||||
fromLabel = src.accountBriefName,
|
||||
@@ -978,12 +999,12 @@ class TransferFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun MibAccount.toDisplayString() = "$accountBriefName · $accountNumber"
|
||||
private fun BankAccount.toDisplayString() = "$accountBriefName · $accountNumber"
|
||||
|
||||
// items: String = section header, MibAccount = selectable row
|
||||
// items: String = section header, BankAccount = selectable row
|
||||
private inner class AccountDropdownAdapter(
|
||||
private val context: Context,
|
||||
accounts: List<MibAccount>
|
||||
accounts: List<BankAccount>
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
private val items: List<Any> = buildList {
|
||||
@@ -996,7 +1017,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
|
||||
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
|
||||
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
||||
|
||||
override fun getCount() = items.size
|
||||
@@ -1021,7 +1042,7 @@ class TransferFragment : Fragment() {
|
||||
b.tvHeader.text = item
|
||||
b.root
|
||||
} else {
|
||||
val acc = item as MibAccount
|
||||
val acc = item as BankAccount
|
||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||
convertView.tag as ItemAccountDropdownBinding
|
||||
} else {
|
||||
|
||||
@@ -25,12 +25,12 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
@@ -48,12 +48,12 @@ class TransferHistoryFragment : Fragment() {
|
||||
private lateinit var adapter: TransactionAdapter
|
||||
|
||||
// All merged transactions sorted by date desc
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private val allTransactions = mutableListOf<BankTransaction>()
|
||||
private var searchQuery = ""
|
||||
|
||||
// Per-account pagination state
|
||||
private data class AccountState(
|
||||
val account: MibAccount,
|
||||
val account: BankAccount,
|
||||
var mibNextStart: Int = 1,
|
||||
var mibTotalCount: Int = -1,
|
||||
var bmlNextPage: Int = 1,
|
||||
@@ -86,6 +86,8 @@ class TransferHistoryFragment : Fragment() {
|
||||
adapter = TransactionAdapter()
|
||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
@@ -156,7 +158,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
|
||||
lifecycleScope.launch {
|
||||
val newTransactions = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<Transaction>()
|
||||
val results = mutableListOf<BankTransaction>()
|
||||
|
||||
// BML accounts: fetch in parallel
|
||||
val bmlStates = activeStates.filter { it.account.bank == "BML" }
|
||||
@@ -170,7 +172,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
state.cardMonthOffset++
|
||||
BmlLoginFlow().fetchCardHistory(
|
||||
BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
@@ -180,7 +182,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
@@ -192,7 +194,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
list
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emptyList<Transaction>() }
|
||||
} catch (_: Exception) { emptyList<BankTransaction>() }
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
@@ -201,9 +203,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
for (state in fahipayStates) {
|
||||
val session = app.fahipaySessionFor(state.account) ?: continue
|
||||
try {
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
val (list, total) = flow.fetchHistory(
|
||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
accountNumber = state.account.accountNumber,
|
||||
|
||||
@@ -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,20 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class CredentialsFragment : Fragment() {
|
||||
|
||||
@@ -46,6 +52,11 @@ 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<BankAccount>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -182,7 +193,7 @@ class CredentialsFragment : Fragment() {
|
||||
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
|
||||
withContext(Dispatchers.IO) {
|
||||
flow.lastSession?.let { s ->
|
||||
val profile = flow.fetchPersonalProfile(s)
|
||||
val profile = MibProfileClient().fetchPersonalProfile(s)
|
||||
if (profile != null) store.saveMibUserProfile(
|
||||
loginId,
|
||||
CredentialStore.MibUserProfile(
|
||||
@@ -232,20 +243,68 @@ class CredentialsFragment : Fragment() {
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val loginId = username
|
||||
val flow = BmlLoginFlow()
|
||||
bmlLoginId = username
|
||||
bmlAccumulatedAccounts.clear()
|
||||
|
||||
val flow = BmlLoginFlow().also { bmlFlow = it }
|
||||
val loginTag = "bml_$username"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val (session, accounts) = withContext(Dispatchers.IO) {
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
flow.login(username, password, otpSeed)
|
||||
}
|
||||
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
|
||||
|
||||
var hasBusinessProfiles = false
|
||||
for (profile in profiles) {
|
||||
if (profile.profileType == "business") {
|
||||
hasBusinessProfiles = true
|
||||
continue // skip — user can enable in Settings → Logins
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
|
||||
if (result is BmlActivationResult.Success) {
|
||||
bmlAccumulatedAccounts += result.accounts
|
||||
val store = CredentialStore(requireContext())
|
||||
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlSessions[profile.profileId] = result.session
|
||||
}
|
||||
}
|
||||
|
||||
val store = CredentialStore(requireContext())
|
||||
store.saveBmlCredentials(loginId, username, password, otpSeed)
|
||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
||||
withContext(Dispatchers.IO) {
|
||||
val info = flow.fetchUserInfo(session)
|
||||
if (info != null) store.saveBmlUserProfile(
|
||||
loginId,
|
||||
store.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
|
||||
|
||||
finishBmlLogin(hasBusinessProfiles)
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
|
||||
val store = CredentialStore(requireContext())
|
||||
val accounts = bmlAccumulatedAccounts.toList()
|
||||
|
||||
// Fetch user profile info from any active session
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val anySession = app.anyBmlSessionFor(bmlLoginId)
|
||||
if (anySession != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val info = BmlAccountClient().fetchUserInfo(anySession)
|
||||
if (info != null) {
|
||||
store.saveBmlUserProfile(
|
||||
bmlLoginId,
|
||||
CredentialStore.BmlUserProfile(
|
||||
fullName = info.fullName,
|
||||
email = info.email,
|
||||
@@ -255,24 +314,47 @@ class CredentialsFragment : Fragment() {
|
||||
birthdate = info.birthdate
|
||||
)
|
||||
)
|
||||
// Single-profile accounts used username as a temporary profileId.
|
||||
// Replace it with the real BML customer ID so multi-login doesn't collide.
|
||||
val customerId = info.customerId
|
||||
if (customerId.isNotBlank()) {
|
||||
val profiles = store.loadBmlProfiles(bmlLoginId)
|
||||
val autoProfile = profiles.firstOrNull { it.autoActivated }
|
||||
if (autoProfile != null && autoProfile.profileId != customerId) {
|
||||
val oldId = autoProfile.profileId
|
||||
// Re-key session in memory and storage
|
||||
val session = app.bmlSessions.remove(oldId)
|
||||
if (session != null) {
|
||||
app.bmlSessions[customerId] = session
|
||||
store.clearBmlProfileSession(oldId)
|
||||
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
||||
}
|
||||
// Update stored profile list with the real ID
|
||||
val updatedProfiles = profiles.map {
|
||||
if (it.autoActivated) it.copy(profileId = customerId) else it
|
||||
}
|
||||
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
|
||||
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
|
||||
// Update accounts to use real profileId
|
||||
bmlAccumulatedAccounts.replaceAll { acc ->
|
||||
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountCache.saveBml(requireContext(), loginId, accounts)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlSessions[loginId] = session
|
||||
// Merge with any existing BML accounts from other logins
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
|
||||
app.accounts = app.accounts + accounts
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
} finally {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||
|
||||
if (hasBusinessProfiles) {
|
||||
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun attemptFahipayLogin() {
|
||||
@@ -376,13 +458,13 @@ class CredentialsFragment : Fragment() {
|
||||
store: CredentialStore
|
||||
) {
|
||||
val (profile, balance) = withContext(Dispatchers.IO) {
|
||||
val p = flow.fetchProfile(session)
|
||||
val b = flow.fetchBalance(session)
|
||||
val p = FahipayAccountClient().fetchProfile(session)
|
||||
val b = FahipayAccountClient().fetchBalance(session)
|
||||
Pair(p, b)
|
||||
}
|
||||
val loginId = profile.profileId
|
||||
val loginTag = "fahipay_$loginId"
|
||||
val account = flow.buildAccount(profile, balance, loginTag)
|
||||
val account = FahipayAccountClient().buildAccount(profile, balance, loginTag)
|
||||
store.saveFahipayCredentials(loginId, idCard, password)
|
||||
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
|
||||
store.saveFahipayUserProfile(
|
||||
|
||||
@@ -3,7 +3,7 @@ package sh.sar.basedbank.util
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
object AccountCache {
|
||||
|
||||
@@ -12,7 +12,7 @@ object AccountCache {
|
||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||
|
||||
fun save(context: Context, accounts: List<MibAccount>) {
|
||||
fun save(context: Context, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
@@ -38,7 +38,7 @@ object AccountCache {
|
||||
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
|
||||
fun saveBml(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
@@ -61,14 +61,14 @@ object AccountCache {
|
||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadBml(context: Context, loginId: String): List<MibAccount> {
|
||||
fun loadBml(context: Context, loginId: String): List<BankAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
BankAccount(
|
||||
bank = "BML",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
@@ -89,10 +89,10 @@ object AccountCache {
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
|
||||
fun loadBml(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadBml(context, it) }
|
||||
|
||||
fun saveFahipay(context: Context, loginId: String, accounts: List<MibAccount>) {
|
||||
fun saveFahipay(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
@@ -115,14 +115,14 @@ object AccountCache {
|
||||
.edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadFahipay(context: Context, loginId: String): List<MibAccount> {
|
||||
fun loadFahipay(context: Context, loginId: String): List<BankAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(fahipayKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
BankAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
@@ -143,14 +143,14 @@ object AccountCache {
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadFahipay(context: Context, loginIds: List<String>): List<MibAccount> =
|
||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadFahipay(context, it) }
|
||||
|
||||
fun clear(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
fun load(context: Context): List<MibAccount> {
|
||||
fun load(context: Context): List<BankAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_MIB, null) ?: return emptyList()
|
||||
return try {
|
||||
@@ -158,7 +158,7 @@ object AccountCache {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
BankAccount(
|
||||
bank = o.optString("bank", "MIB"),
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||
|
||||
object AccountHistoryParser {
|
||||
|
||||
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
|
||||
fun from(account: BankAccount): AccountHistoryDisplay? = when (account.bank) {
|
||||
"BML" -> BmlHistoryParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||
"MIB" -> MibHistoryParser.displayData(account)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||
|
||||
object AccountListParser {
|
||||
|
||||
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
|
||||
fun from(account: BankAccount): AccountListDisplay? = when (account.bank) {
|
||||
"BML" -> BmlDashboardParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||
"MIB" -> MibAccountParser.displayData(account)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.bmlapi.BmlContactParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
|
||||
import sh.sar.basedbank.util.mibapi.MibContactParser
|
||||
|
||||
object ContactListParser {
|
||||
|
||||
fun from(contact: MibBeneficiary): ContactDisplay? = when {
|
||||
fun from(contact: BankContact): ContactDisplay? = when {
|
||||
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
|
||||
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
|
||||
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
|
||||
fun fromList(contacts: List<BankContact>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
|
||||
/**
|
||||
@@ -21,7 +21,7 @@ object ContactManager {
|
||||
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||
val contactId = contact.id.removePrefix("bml_")
|
||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||
return try { BmlContactsClient().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||
|
||||
@@ -3,8 +3,8 @@ package sh.sar.basedbank.util
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
|
||||
object ContactsCache {
|
||||
|
||||
@@ -14,8 +14,8 @@ object ContactsCache {
|
||||
|
||||
fun save(
|
||||
context: Context,
|
||||
contacts: List<MibBeneficiary>,
|
||||
categories: List<MibBeneficiaryCategory>
|
||||
contacts: List<BankContact>,
|
||||
categories: List<BankContactCategory>
|
||||
) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
|
||||
@@ -55,7 +55,7 @@ object ContactsCache {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
fun loadContacts(context: Context): List<MibBeneficiary> {
|
||||
fun loadContacts(context: Context): List<BankContact> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
||||
return try {
|
||||
@@ -63,7 +63,7 @@ object ContactsCache {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibBeneficiary(
|
||||
BankContact(
|
||||
benefNo = o.optString("benefNo"),
|
||||
benefName = o.optString("benefName"),
|
||||
benefNickName = o.optString("benefNickName"),
|
||||
@@ -86,7 +86,7 @@ object ContactsCache {
|
||||
|
||||
private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
|
||||
|
||||
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
|
||||
fun saveBml(context: Context, loginId: String, contacts: List<BankContact>) {
|
||||
val arr = JSONArray()
|
||||
for (c in contacts) {
|
||||
arr.put(JSONObject().apply {
|
||||
@@ -108,14 +108,14 @@ object ContactsCache {
|
||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
|
||||
fun loadBml(context: Context, loginId: String): List<BankContact> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibBeneficiary(
|
||||
BankContact(
|
||||
benefNo = o.optString("benefNo"),
|
||||
benefName = o.optString("benefName"),
|
||||
benefNickName = o.optString("benefNickName"),
|
||||
@@ -134,10 +134,10 @@ object ContactsCache {
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
|
||||
fun loadBml(context: Context, loginIds: List<String>): List<BankContact> =
|
||||
loginIds.flatMap { loadBml(context, it) }
|
||||
|
||||
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
|
||||
fun saveFahipay(context: Context, contacts: List<BankContact>, categories: List<BankContactCategory>) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
val arr = JSONArray()
|
||||
for (c in contacts) arr.put(JSONObject().apply {
|
||||
@@ -159,14 +159,14 @@ object ContactsCache {
|
||||
prefs.apply()
|
||||
}
|
||||
|
||||
fun loadFahipay(context: Context): List<MibBeneficiary> {
|
||||
fun loadFahipay(context: Context): List<BankContact> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString("fahipay_contacts", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibBeneficiary(
|
||||
BankContact(
|
||||
benefNo = o.optString("benefNo"),
|
||||
benefName = "",
|
||||
benefNickName = o.optString("benefNickName"),
|
||||
@@ -185,19 +185,19 @@ object ContactsCache {
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadFahipayCategories(context: Context): List<MibBeneficiaryCategory> {
|
||||
fun loadFahipayCategories(context: Context): List<BankContactCategory> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString("fahipay_categories", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibBeneficiaryCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
|
||||
BankContactCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
|
||||
fun loadCategories(context: Context): List<BankContactCategory> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
||||
return try {
|
||||
@@ -205,7 +205,7 @@ object ContactsCache {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibBeneficiaryCategory(
|
||||
BankContactCategory(
|
||||
id = o.optString("id"),
|
||||
categoryName = o.optString("categoryName"),
|
||||
numBenef = o.optInt("numBenef", 0)
|
||||
|
||||
@@ -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,85 @@ class CredentialStore(context: Context) {
|
||||
}
|
||||
|
||||
fun clearBmlCredentials(loginId: String) {
|
||||
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
|
||||
removeBmlLoginId(loginId)
|
||||
prefs.edit()
|
||||
.remove("bml_${loginId}_enc_username")
|
||||
.remove("bml_${loginId}_enc_password")
|
||||
.remove("bml_${loginId}_enc_otp_seed")
|
||||
.remove("bml_${loginId}_profiles")
|
||||
.remove("bml_${loginId}_hidden_profile_ids")
|
||||
// legacy single-profile session keys
|
||||
.remove("bml_${loginId}_enc_token")
|
||||
.remove("bml_${loginId}_enc_device_id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ── BML session token (per loginId) ───────────────────────────────────────
|
||||
// ── BML profiles (per loginId) ────────────────────────────────────────────
|
||||
|
||||
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
|
||||
fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
|
||||
val arr = org.json.JSONArray()
|
||||
for (p in profiles) arr.put(org.json.JSONObject().apply {
|
||||
put("profileId", p.profileId)
|
||||
put("name", p.name)
|
||||
put("type", p.type)
|
||||
put("profileType", p.profileType)
|
||||
put("autoActivated", p.autoActivated)
|
||||
})
|
||||
prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadBmlProfiles(loginId: String): List<BmlProfile> {
|
||||
val raw = prefs.getString("bml_${loginId}_profiles", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = org.json.JSONArray(raw)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BmlProfile(
|
||||
profileId = o.optString("profileId"),
|
||||
name = o.optString("name"),
|
||||
type = o.optString("type"),
|
||||
profileType = o.optString("profileType", "default"),
|
||||
autoActivated = o.optBoolean("autoActivated", false)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun getHiddenBmlProfileIds(loginId: String): Set<String> =
|
||||
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
|
||||
prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply()
|
||||
|
||||
// ── BML per-profile session token (keyed by profileId, a globally unique GUID) ──
|
||||
|
||||
fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) {
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
|
||||
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
|
||||
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
|
||||
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
|
||||
val key = getOrCreateKey()
|
||||
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
|
||||
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
|
||||
return try {
|
||||
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearBmlProfileSession(profileId: String) {
|
||||
prefs.edit()
|
||||
.remove("bml_profile_${profileId}_enc_token")
|
||||
.remove("bml_profile_${profileId}_enc_device_id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ── Legacy single-profile BML session (kept for backward compat reading) ─
|
||||
|
||||
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
||||
val key = getOrCreateKey()
|
||||
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
||||
@@ -220,13 +282,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> {
|
||||
|
||||
@@ -4,11 +4,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
@@ -18,7 +18,7 @@ import java.util.Locale
|
||||
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
|
||||
* without knowing which bank it is talking to.
|
||||
*/
|
||||
class HistoryFetcher(private val account: MibAccount) {
|
||||
class HistoryFetcher(private val account: BankAccount) {
|
||||
|
||||
private val isMib get() = account.bank == "MIB"
|
||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||
@@ -46,18 +46,16 @@ class HistoryFetcher(private val account: MibAccount) {
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
|
||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||
}
|
||||
|
||||
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
|
||||
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
val (list, total) = flow.fetchHistory(
|
||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
@@ -68,7 +66,7 @@ class HistoryFetcher(private val account: MibAccount) {
|
||||
return list
|
||||
}
|
||||
|
||||
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
|
||||
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<BankTransaction> {
|
||||
val loginId = account.loginTag.removePrefix("mib_")
|
||||
val session = app.mibSessions[loginId] ?: return emptyList()
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
@@ -86,13 +84,13 @@ class HistoryFetcher(private val account: MibAccount) {
|
||||
return list
|
||||
}
|
||||
|
||||
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
|
||||
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
return BmlLoginFlow().fetchCardHistory(
|
||||
return BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
@@ -101,9 +99,9 @@ class HistoryFetcher(private val account: MibAccount) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
|
||||
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
|
||||
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
@@ -0,0 +1,83 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import java.io.File
|
||||
|
||||
/** Persistent (non-cache) store for completed transfer receipts shown in Recent Transfers. */
|
||||
object ReceiptStore {
|
||||
|
||||
private const val FILE_NAME = "activities.json"
|
||||
|
||||
data class Entry(val data: TransferReceiptData, val savedAt: Long)
|
||||
|
||||
fun save(context: Context, receipt: TransferReceiptData) {
|
||||
val existing = loadAll(context).toMutableList()
|
||||
existing.add(0, Entry(receipt, System.currentTimeMillis()))
|
||||
writeAll(context, existing)
|
||||
}
|
||||
|
||||
fun loadAll(context: Context): List<Entry> {
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (!file.exists()) return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
Entry(
|
||||
data = TransferReceiptData(
|
||||
bank = o.optString("bank", "MIB"),
|
||||
amount = o.optString("amount"),
|
||||
currency = o.optString("currency"),
|
||||
fromLabel = o.optString("fromLabel"),
|
||||
fromColorHex = o.optString("fromColorHex"),
|
||||
fromProfileImageHash = o.optString("fromProfileImageHash").takeIf { it.isNotBlank() },
|
||||
toLabel = o.optString("toLabel"),
|
||||
toAccount = o.optString("toAccount"),
|
||||
toBank = o.optString("toBank"),
|
||||
remarks = o.optString("remarks"),
|
||||
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||
bmlFromName = o.optString("bmlFromName"),
|
||||
bmlReference = o.optString("bmlReference"),
|
||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||
bmlMessage = o.optString("bmlMessage")
|
||||
),
|
||||
savedAt = o.optLong("savedAt", 0L)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
File(context.filesDir, FILE_NAME).delete()
|
||||
}
|
||||
|
||||
private fun writeAll(context: Context, items: List<Entry>) {
|
||||
try {
|
||||
val arr = JSONArray()
|
||||
for ((d, ts) in items) arr.put(JSONObject().apply {
|
||||
put("bank", d.bank)
|
||||
put("amount", d.amount)
|
||||
put("currency", d.currency)
|
||||
put("fromLabel", d.fromLabel)
|
||||
put("fromColorHex", d.fromColorHex)
|
||||
put("fromProfileImageHash", d.fromProfileImageHash ?: "")
|
||||
put("toLabel", d.toLabel)
|
||||
put("toAccount", d.toAccount)
|
||||
put("toBank", d.toBank)
|
||||
put("remarks", d.remarks)
|
||||
put("mibReferenceNo", d.mibReferenceNo)
|
||||
put("mibTransactionDate", d.mibTransactionDate)
|
||||
put("bmlFromName", d.bmlFromName)
|
||||
put("bmlReference", d.bmlReference)
|
||||
put("bmlTimestamp", d.bmlTimestamp)
|
||||
put("bmlMessage", d.bmlMessage)
|
||||
put("savedAt", ts)
|
||||
})
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package sh.sar.basedbank.util.bmlapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
object BmlContactParser {
|
||||
|
||||
fun displayData(contact: MibBeneficiary) = ContactDisplay(
|
||||
fun displayData(contact: BankContact) = ContactDisplay(
|
||||
id = contact.benefNo,
|
||||
name = contact.benefNickName,
|
||||
realName = contact.benefName,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package sh.sar.basedbank.util.bmlapi
|
||||
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
|
||||
object BmlDashboardParser {
|
||||
@@ -10,7 +10,7 @@ object BmlDashboardParser {
|
||||
* Returns all display fields for an account/card row in the accounts list.
|
||||
* Handles both BML CASA accounts and BML prepaid/credit cards.
|
||||
*/
|
||||
fun displayData(account: MibAccount): AccountListDisplay {
|
||||
fun displayData(account: BankAccount): AccountListDisplay {
|
||||
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||
return if (isCard) {
|
||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
||||
@@ -51,7 +51,7 @@ object BmlDashboardParser {
|
||||
}
|
||||
|
||||
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
|
||||
fun listBalance(account: MibAccount): String =
|
||||
fun listBalance(account: BankAccount): String =
|
||||
"${account.currencyName} ${account.currentBalance}"
|
||||
|
||||
fun cardBrandIcon(productName: String): Int = when {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package sh.sar.basedbank.util.bmlapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
|
||||
object BmlHistoryParser {
|
||||
|
||||
fun displayData(account: MibAccount): AccountHistoryDisplay {
|
||||
fun displayData(account: BankAccount): AccountHistoryDisplay {
|
||||
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
|
||||
return AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package sh.sar.basedbank.util.fahipayapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
|
||||
object FahipayAccountParser {
|
||||
|
||||
fun displayData(account: MibAccount) = AccountListDisplay(
|
||||
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
typeLabel = account.accountTypeName,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package sh.sar.basedbank.util.fahipayapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
object FahipayContactParser {
|
||||
|
||||
fun displayData(contact: MibBeneficiary) = ContactDisplay(
|
||||
fun displayData(contact: BankContact) = ContactDisplay(
|
||||
id = contact.benefNo,
|
||||
name = contact.benefNickName,
|
||||
realName = contact.benefName,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package sh.sar.basedbank.util.fahipayapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
|
||||
object FahipayHistoryParser {
|
||||
|
||||
fun displayData(account: MibAccount) = AccountHistoryDisplay(
|
||||
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
bankPill = "FP",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package sh.sar.basedbank.util.mibapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
|
||||
object MibAccountParser {
|
||||
|
||||
fun displayData(account: MibAccount) = AccountListDisplay(
|
||||
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
typeLabel = productLabel(account.accountTypeName),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package sh.sar.basedbank.util.mibapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
object MibContactParser {
|
||||
|
||||
fun displayData(contact: MibBeneficiary): ContactDisplay {
|
||||
fun displayData(contact: BankContact): ContactDisplay {
|
||||
val network = when (contact.benefType) {
|
||||
"I" -> TransferNetwork.MIB
|
||||
"S" -> TransferNetwork.SWIFT
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package sh.sar.basedbank.util.mibapi
|
||||
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
|
||||
object MibHistoryParser {
|
||||
|
||||
fun displayData(account: MibAccount) = AccountHistoryDisplay(
|
||||
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
bankPill = null, // MIB has no bank pill
|
||||
|
||||
10
app/src/main/res/drawable/ic_channel_email.xml
Normal file
10
app/src/main/res/drawable/ic_channel_email.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5V6l8,5 8,-5v2z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_channel_sms.xml
Normal file
10
app/src/main/res/drawable/ic_channel_sms.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM9,11H7V9h2v2zM13,11h-2V9h2v2zM17,11h-2V9h2v2z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_channel_whatsapp.xml
Normal file
10
app/src/main/res/drawable/ic_channel_whatsapp.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M26.576 5.363c-2.69-2.69-6.406-4.354-10.511-4.354-8.209 0-14.865 6.655-14.865 14.865 0 2.732 0.737 5.291 2.022 7.491l-0.038-0.070-2.109 7.702 7.879-2.067c2.051 1.139 4.498 1.809 7.102 1.809h0.006c8.209-0.003 14.862-6.659 14.862-14.868 0-4.103-1.662-7.817-4.349-10.507zM16.062 28.228h-0.005c-2.319 0-4.489-0.64-6.342-1.753l0.056 0.031-0.451-0.267-4.675 1.227 1.247-4.559-0.294-0.467c-1.185-1.862-1.889-4.131-1.889-6.565 0-6.822 5.531-12.353 12.353-12.353s12.353 5.531 12.353 12.353c0 6.822-5.53 12.353-12.353 12.353zM22.838 18.977c-0.371-0.186-2.197-1.083-2.537-1.208-0.341-0.124-0.589-0.185-0.837 0.187-0.246 0.371-0.958 1.207-1.175 1.455-0.216 0.249-0.434 0.279-0.805 0.094-1.15-0.466-2.138-1.087-2.997-1.852l0.010 0.009c-0.799-0.74-1.484-1.587-2.037-2.521l-0.028-0.052c-0.216-0.371-0.023-0.572 0.162-0.757 0.167-0.166 0.372-0.434 0.557-0.65 0.146-0.179 0.271-0.384 0.366-0.604l0.006-0.017c0.043-0.087 0.068-0.188 0.068-0.296 0-0.131-0.037-0.253-0.101-0.357l0.002 0.003c-0.094-0.186-0.836-2.014-1.145-2.758-0.302-0.724-0.609-0.625-0.836-0.637-0.216-0.010-0.464-0.012-0.712-0.012-0.395 0.010-0.746 0.188-0.988 0.463l-0.001 0.002c-0.802 0.761-1.3 1.834-1.3 3.023 0 0.026 0 0.053 0.001 0.079c0.131 1.467 0.681 2.784 1.527 3.857l-0.012-0.015c1.604 2.379 3.742 4.282 6.251 5.564l0.094 0.043c0.548 0.248 1.25 0.513 1.968 0.74l0.149 0.041c0.442 0.14 0.951 0.221 1.479 0.221 0.303 0 0.601-0.027 0.889-0.078l-0.031 0.004c1.069-0.223 1.956-0.868 2.497-1.749l0.009-0.017c0.165-0.366 0.261-0.793 0.261-1.242 0-0.185-0.016-0.366-0.047-0.542l0.003 0.019c-0.092-0.155-0.34-0.247-0.712-0.434z"/>
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_save.xml
Normal file
13
app/src/main/res/drawable/ic_save.xml
Normal 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
app/src/main/res/drawable/ic_share.xml
Normal file
13
app/src/main/res/drawable/ic_share.xml
Normal 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>
|
||||
11
app/src/main/res/drawable/ic_visibility.xml
Normal file
11
app/src/main/res/drawable/ic_visibility.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39-6,-7.5-11,-7.5zM12,17c-2.76,0-5,-2.24-5,-5s2.24,-5 5,-5 5,2.24 5,5-2.24,5-5,5zM12,9c-1.66,0-3,1.34-3,3s1.34,3 3,3 3,-1.34 3,-3-1.34,-3-3,-3z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_visibility_off.xml
Normal file
11
app/src/main/res/drawable/ic_visibility_off.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65-0.13,1.26-0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75-1.73,-4.39-6,-7.5-11,-7.5-1.4,0-2.74,0.25-3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21-0.08,0.43-0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33-1.41,0.53-2.2,0.53-2.76,0-5,-2.24-5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66-1.34,-3-3,-3l-0.17,0.01z" />
|
||||
</vector>
|
||||
@@ -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>
|
||||
|
||||
|
||||
59
app/src/main/res/layout/fragment_activities.xml
Normal file
59
app/src/main/res/layout/fragment_activities.xml
Normal file
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
app:startIconDrawable="@android:drawable/ic_menu_search"
|
||||
app:boxCornerRadiusTopStart="24dp"
|
||||
app:boxCornerRadiusTopEnd="24dp"
|
||||
app:boxCornerRadiusBottomStart="24dp"
|
||||
app:boxCornerRadiusBottomEnd="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSearch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Search"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:imeOptions="actionSearch" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="No recent transfers"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
118
app/src/main/res/layout/fragment_pay_mv_qr.xml
Normal file
118
app/src/main/res/layout/fragment_pay_mv_qr.xml
Normal file
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<!-- QR card fills all available space above the inputs -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvQrPlaceholder"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="Select an account"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivQrCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="@string/pay_mv_qr" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Account dropdown -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAccount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:hint="@string/paymvqr_select_account">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/actvAccount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="none"
|
||||
android:focusable="false"
|
||||
android:focusableInTouchMode="false" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Amount (optional) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAmount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:hint="@string/paymvqr_amount_hint"
|
||||
app:helperText="@string/paymvqr_amount_helper"
|
||||
app:prefixText="MVR ">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAmount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutActions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnShare"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/paymvqr_share"
|
||||
app:icon="@drawable/ic_share" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSave"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/paymvqr_save_image"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanQr"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/transfer_scan_qr"
|
||||
app:icon="@drawable/ic_qr_scan" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -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"
|
||||
|
||||
@@ -162,6 +162,44 @@
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowHideAmounts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_hide_amounts"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_hide_amounts_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchHideAmounts"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowBlockScreenshots"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_hide_amounts"
|
||||
android:icon="@drawable/ic_visibility"
|
||||
android:title="@string/action_hide_amounts"
|
||||
app:showAsAction="always" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_lock"
|
||||
android:icon="@drawable/ic_lock"
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="nav_accounts">އެކައުންޓްތައް</string>
|
||||
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
|
||||
<string name="nav_activities">ހަރަކާތްތައް</string>
|
||||
<string name="nav_transfer_history">ޓްރާންސްފަ ތާރީހް</string>
|
||||
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string>
|
||||
<string name="nav_finances">ފައިނޭންސް</string>
|
||||
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
|
||||
<string name="nav_settings">ސެޓިންގ</string>
|
||||
|
||||
@@ -79,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,8 +99,19 @@
|
||||
<string name="transfer">Transfer</string>
|
||||
<string name="pay_mv_qr">PayMV QR</string>
|
||||
|
||||
<!-- PayMV QR Generator -->
|
||||
<string name="paymvqr_select_account">Select account</string>
|
||||
<string name="paymvqr_amount_hint">Amount (optional)</string>
|
||||
<string name="paymvqr_amount_helper">Leave empty to allow payer to enter any amount</string>
|
||||
<string name="paymvqr_share">Share</string>
|
||||
<string name="paymvqr_save_image">Save Image</string>
|
||||
<string name="paymvqr_saved">QR saved to gallery</string>
|
||||
<string name="paymvqr_save_failed">Failed to save image</string>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<string name="action_lock">Lock app</string>
|
||||
<string name="action_hide_amounts">Hide sensitive information</string>
|
||||
<string name="action_show_amounts">Show sensitive information</string>
|
||||
<string name="autolock_warning_title">Locking soon</string>
|
||||
<string name="autolock_warning_message">Locking in %d seconds</string>
|
||||
<string name="autolock_stay">Stay unlocked</string>
|
||||
@@ -128,6 +139,8 @@
|
||||
<string name="lang_english">English</string>
|
||||
<string name="lang_dhivehi">ދިވެހި</string>
|
||||
<string name="settings_privacy">Privacy</string>
|
||||
<string name="settings_hide_amounts">Hide sensitive information</string>
|
||||
<string name="settings_hide_amounts_desc">Masks account balances and financial figures across the app</string>
|
||||
<string name="settings_block_screenshots">Block Screenshots</string>
|
||||
<string name="settings_block_screenshots_desc">Prevents the app from appearing in the recents screen and blocks screen capture</string>
|
||||
<string name="settings_cache">Cache</string>
|
||||
@@ -159,6 +172,10 @@
|
||||
<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_sent">OTP sent via %s</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="transfer_same_account">This is your source account</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
<svg width="60" height="60" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#FFF" d="M0 59.776h59.785V0H0z"/><path fill="#E21B23" d="M3.297 56.421h53.191V3.356H3.298z"/><path d="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z" fill="#FFF"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2021 (64-Bit) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100px" height="40px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 276 80"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#3F65AD;fill-rule:nonzero}
|
||||
.fil1 {fill:#9AD141;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2901355026512">
|
||||
<path class="fil0" d="M272.33 28.36l-12.05 0 0 -6.16c0,-1 -0.36,-1.87 -1.08,-2.59 -0.73,-0.73 -1.61,-1.09 -2.63,-1.08 -0.95,0 -1.87,0.41 -2.52,1.11 -0.67,0.71 -1.01,1.58 -1.01,2.56l0 6.16 -4.07 0c-4.95,0 -4.66,6.64 0,6.64l4.07 0 0 27.8c0,5.47 1.19,9.74 3.53,12.67 2.41,3.01 6.48,4.53 12.11,4.53l3.65 0c1.72,0 3.16,-1.2 3.54,-2.81l0 -1.63c-0.38,-1.61 -1.82,-2.81 -3.54,-2.81l-3.65 0c-3.2,0 -5.41,-0.79 -6.56,-2.35 -1.22,-1.65 -1.84,-4.22 -1.84,-7.6l0 -27.8 12.05 0c4.74,0 4.74,-6.64 0,-6.64z"/>
|
||||
<path class="fil0" d="M169.85 76.38l0 -41.07 15.31 0c0.74,0 1.47,0.08 2.18,0.27 1.63,0.46 3.04,1.32 4.16,2.55 0.82,0.91 1.49,2.02 1.99,3.29 0.5,1.27 0.76,2.7 0.76,4.24l0 30.72c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.63,-1.62 3.63,-3.62l0 -30.72c0,-2.72 -0.44,-5.17 -1.3,-7.3 -0.86,-2.14 -2.07,-3.98 -3.59,-5.47 -1.53,-1.49 -3.3,-2.63 -5.27,-3.39 -1.97,-0.76 -4.04,-1.14 -6.17,-1.14l-17.43 0c-1.54,0 -2.8,0.54 -3.75,1.61 -0.92,1.04 -1.38,2.26 -1.38,3.62l0 42.79c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.62,-1.62 3.62,-3.62z"/>
|
||||
<path class="fil0" d="M225.3 28.36c15.78,0 18.49,6.01 19.17,19.96 0.27,5.45 -0.89,8.87 -5.93,9.27l-17.03 0c-1.8,0 -3.27,-1.51 -3.27,-3.37 0,-1.86 1.47,-3.37 3.27,-3.37l0.77 0 12.95 0 2.56 0c0,-9.79 0.21,-16.11 -12.49,-15.54 -3.89,0 -6.8,1.13 -8.92,3.46 -2.11,2.32 -3.18,5.53 -3.18,9.52l0.03 9.51c0.09,2.35 0.32,4.45 0.68,6.25 0.39,2 1.04,3.69 1.93,5.03 0.87,1.29 2.03,2.28 3.47,2.94 1.47,0.68 3.39,1.02 5.69,1.02l2 0 4.51 0 3.48 0c1.77,0.01 3.21,1.54 3.21,3.38 0,1.83 -1.44,3.58 -3.21,3.58l-3.48 0 -4.51 0 -2.09 0c-3.63,0 -6.69,-0.5 -9.11,-1.49 -2.48,-1.01 -4.48,-2.57 -5.96,-4.64 -1.44,-2.03 -2.47,-4.6 -3.04,-7.62 -0.57,-2.94 -0.85,-6.42 -0.85,-10.37l0 -7.59c0,-3.11 0.5,-5.94 1.49,-8.42 0.99,-2.49 2.38,-4.61 4.14,-6.3 1.75,-1.68 3.81,-2.99 6.14,-3.88 2.32,-0.88 4.87,-1.33 7.58,-1.33z"/>
|
||||
<path class="fil1" d="M72.13 28.36c-1.86,0 -3.37,1.55 -3.37,3.47l0 44.7c0,1.92 1.51,3.47 3.37,3.47 1.86,0 3.36,-1.55 3.36,-3.47l0 -44.7c0,-1.92 -1.5,-3.47 -3.36,-3.47z"/>
|
||||
<path class="fil1" d="M62.69 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.22,1.49 -3.22,3.32 0,1.83 1.44,3.32 3.22,3.32l12.86 0c2.22,0 3.9,0.81 5.13,2.46 1.28,1.7 1.92,3.84 1.92,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.28,-1.6 -1.68,-2.57 -0.39,-0.96 -0.59,-2.02 -0.59,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.57,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.8,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.47,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.09,4.74 -0.73,1.8 -1.11,3.76 -1.11,5.83 0,2.07 0.38,4.03 1.11,5.83 0.74,1.79 1.78,3.38 3.09,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.77,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.34,-4.13 -1,-5.99z"/>
|
||||
<path class="fil1" d="M26.66 28.36l-9.83 0 0 -7.66 0 0c0,-1.08 0,-1.83 0,-1.83 0,-3.31 1.04,-6.76 3.05,-9.42 1.11,-1.47 2.8,-2.4 4.63,-2.77 1.27,-0.25 5.79,-0.34 7.59,-0.35l0 0c1.75,0 3.16,-1.42 3.16,-3.17 0,-1.75 -1.41,-3.16 -3.16,-3.16 -3.39,0.02 -9.41,0.24 -12.15,1.58 -3.98,1.94 -7.03,5.42 -8.56,9.56 -0.85,2.28 -1.27,4.73 -1.27,7.34l0 9.88 -6.84 0c-1.75,0 -3.17,1.42 -3.17,3.16 0,1.75 1.42,3.48 3.17,3.48l0 0 6.84 0 0 41.64c0,1.86 1.5,3.36 3.36,3.36 1.85,0 3.35,-1.5 3.35,-3.36l0 -41.64 9.83 0 0 0.01c1.75,0 3.17,-1.74 3.17,-3.48 0,-1.75 -1.42,-3.17 -3.17,-3.17z"/>
|
||||
<path class="fil1" d="M156.53 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.21,1.49 -3.21,3.32 0,1.83 1.43,3.32 3.21,3.32l12.86 0c2.22,0 3.9,0.81 5.14,2.46 1.27,1.7 1.91,3.84 1.91,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.27,-1.6 -1.67,-2.57 -0.4,-0.96 -0.6,-2.02 -0.6,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.58,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.81,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.46,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.08,4.74 -0.74,1.8 -1.12,3.76 -1.12,5.83 0,2.07 0.38,4.03 1.12,5.83 0.73,1.79 1.77,3.38 3.08,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.78,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.33,-4.13 -1,-5.99z"/>
|
||||
<path class="fil1" d="M112.72 54.91c-2.71,-2.17 -6.46,-3.62 -11.16,-4.29l-3.62 -0.49c-3.92,-0.51 -6.78,-1.31 -8.51,-2.38 -1.58,-0.98 -2.35,-2.65 -2.35,-5.09 0,-2.76 0.9,-4.65 2.73,-5.77 1.95,-1.19 4.4,-1.89 7.34,-1.89l15.72 0.01 0 0c1.78,0 3.22,-1.48 3.22,-3.32 0,-1.83 -1.44,-3.32 -3.22,-3.32l-15.72 -0.01c-2.21,0 -4.33,0.35 -6.36,0.85 -2.06,0.52 -3.88,1.36 -5.42,2.5 -1.56,1.15 -2.83,2.67 -3.78,4.51 -0.94,1.85 -1.43,4.09 -1.43,6.64 0,4.73 1.51,8.17 4.47,10.23 2.82,1.97 6.79,3.23 11.78,3.77l4.07 0.49c3.13,0.43 5.55,1.33 7.2,2.66 1.58,1.28 2.34,2.91 2.34,5 0,3.16 -0.98,5.27 -2.99,6.45 -2.15,1.26 -5.11,1.9 -8.81,1.9l-14.83 0 0 0c-1.78,0 -3.23,1.48 -3.23,3.32 0,1.83 1.45,3.32 3.23,3.32l14.83 0c2.71,0 5.21,-0.24 7.44,-0.7 2.29,-0.48 4.29,-1.32 5.93,-2.49 1.66,-1.19 2.98,-2.77 3.93,-4.69 0.94,-1.91 1.42,-4.33 1.42,-7.2 0,-4.39 -1.42,-7.75 -4.22,-10.01z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
Reference in New Issue
Block a user