Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
da85a31bc6
|
|||
|
d292e73fd9
|
|||
|
3d632606a0
|
|||
|
6daeb5f72e
|
|||
|
c4d3c1efd4
|
|||
|
0560c53ae3
|
|||
|
a37454de00
|
|||
|
daf9b0475a
|
|||
|
c4ad35e6b9
|
|||
|
3e8ea90701
|
|||
|
ef919aa179
|
|||
|
c98a3e3e89
|
|||
|
0654c711d6
|
|||
|
b67368c94a
|
|||
|
a6e7e61b58
|
|||
|
e974a95708
|
|||
|
de11fbe0d3
|
|||
|
5d8ab76477
|
|||
|
d637877167
|
|||
|
ea227bf3b9
|
|||
|
6b3131069e
|
|||
|
8037ce3f02
|
|||
|
cecf0bedfc
|
|||
|
256f216da4
|
|||
|
0a27de4a34
|
|||
|
a3f8852163
|
|||
|
8e345746ed
|
@@ -1,11 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
release:
|
release:
|
||||||
# image: git.shihaam.dev/dockerfiles/android-builder
|
|
||||||
image: git.shihaam.dev/dockerfiles/runners/gradle
|
image: git.shihaam.dev/dockerfiles/runners/gradle
|
||||||
hostname: isodroid
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./release:/release
|
- ./release:/release
|
||||||
- ../../:/source
|
- ../../:/source
|
||||||
# - /root/.cache/cache-runners/gradle:/root/.gradle
|
- /root/.cache/cache-runners/gradle:/root/.gradle
|
||||||
|
|||||||
Generated
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<component name="deploymentTargetSelector">
|
<component name="deploymentTargetSelector">
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DIALOG" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
|
|||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="previewPanelProviderInfo">
|
||||||
|
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# BasedBank
|
# Thijooree
|
||||||
|
|
||||||
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves.
|
A native Android client for Maldivian banking services. It is a pure client: requests go directly from your device to the banks' own servers using the same protocols as their official apps. No proxy, no backend, no middleman.
|
||||||
|
|
||||||
[](https://sladge.net)
|
[](https://sladge.net)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@@ -8,60 +8,14 @@ A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
|
|
||||||
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
|
|
||||||
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
|
|
||||||
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
|
|
||||||
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
|
|
||||||
- **QR payments** — scan PayMV QR codes to pre-fill transfers
|
|
||||||
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
|
|
||||||
- **MIB financing** — view active financing deals
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
|
|
||||||
|
|
||||||
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
|
|
||||||
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
|
|
||||||
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
|
|
||||||
- All sensitive data excluded from Android cloud backup
|
|
||||||
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
|
|
||||||
|
|
||||||
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
|
|
||||||
|
|
||||||
## Supported banks
|
|
||||||
|
|
||||||
| Bank | Login | Accounts | History | Transfers | Contacts |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Android 8.0+ (API 26)
|
- Android 8.0+ (API 26)
|
||||||
- Existing accounts with MIB, BML, or Fahipay
|
- Existing accounts with MIB, BML, or Fahipay
|
||||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||||
|
|
||||||
## Building
|
## Download
|
||||||
|
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
|
||||||
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
|
|
||||||
|
|
||||||
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
|
||||||
|
|
||||||
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
|
||||||
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
|
||||||
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
@@ -70,3 +24,8 @@ No data ever leaves your device except the API calls to the banking services the
|
|||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 7
|
versionCode = 8
|
||||||
versionName = "1.0.8"
|
versionName = "1.0.9"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -30,6 +31,7 @@ class BmlHistoryClient {
|
|||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
return try {
|
return try {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||||
@@ -82,6 +84,7 @@ class BmlHistoryClient {
|
|||||||
val json = resp.body?.string() ?: return emptyList()
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
return try {
|
return try {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ data class BmlLoanDetail(
|
|||||||
val overdueAmount: Double
|
val overdueAmount: Double
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlQrPayInfo(
|
||||||
|
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||||
|
val merchantName: String, // narrative1
|
||||||
|
val merchantAddress: String, // narrative2 + narrative3
|
||||||
|
val amount: Double, // 0.0 for static QR
|
||||||
|
val currency: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmlQrPayResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val merchant: String = "",
|
||||||
|
val amount: String = "",
|
||||||
|
val currency: String = "",
|
||||||
|
val errorMessage: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class BmlQrPayClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a BML QR URL to merchant details.
|
||||||
|
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||||
|
*/
|
||||||
|
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||||
|
val request = bmlApiRequest(session,
|
||||||
|
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string() ?: throw Exception("No response")
|
||||||
|
val json = JSONObject(body)
|
||||||
|
if (!json.optBoolean("success"))
|
||||||
|
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||||
|
val payload = json.getJSONObject("payload")
|
||||||
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
|
||||||
|
BmlQrPayInfo(
|
||||||
|
requestId = payload.optString("trxn_hash"),
|
||||||
|
merchantName = payload.optString("narrative1").trim(),
|
||||||
|
merchantAddress = address,
|
||||||
|
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
|
||||||
|
currency = payload.optString("currency").ifBlank { "MVR" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
|
||||||
|
* POST without channel — expects code 99 (OTP channel selection required).
|
||||||
|
*/
|
||||||
|
fun preInitiatePayment(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String
|
||||||
|
): Boolean {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("action", "approve")
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("requestId", requestId)
|
||||||
|
put("amount", amount)
|
||||||
|
put("currency", currency)
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||||
|
.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 body = response.body?.string() ?: return@use false
|
||||||
|
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||||
|
json.optBoolean("success") && json.optInt("code") == 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 — initiate: POST with channel but no OTP.
|
||||||
|
* Returns true when server responds with code 22 (OTP generated).
|
||||||
|
*/
|
||||||
|
fun initiatePayment(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String
|
||||||
|
): Boolean {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("action", "approve")
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("requestId", requestId)
|
||||||
|
put("amount", amount)
|
||||||
|
put("currency", currency)
|
||||||
|
put("channel", "token")
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||||
|
.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 body = response.body?.string() ?: return@use false
|
||||||
|
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||||
|
json.optBoolean("success") && json.optInt("code") == 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 — confirm: POST with channel + OTP.
|
||||||
|
* Returns [BmlQrPayResult] with success/error.
|
||||||
|
*/
|
||||||
|
fun confirmPayment(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String,
|
||||||
|
otp: String
|
||||||
|
): BmlQrPayResult {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("action", "approve")
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("requestId", requestId)
|
||||||
|
put("amount", amount)
|
||||||
|
put("currency", currency)
|
||||||
|
put("channel", "token")
|
||||||
|
put("otp", otp)
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||||
|
.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 body = response.body?.string()
|
||||||
|
?: return@use BmlQrPayResult(false, errorMessage = "No response")
|
||||||
|
val json = try { JSONObject(body) } catch (_: Exception) {
|
||||||
|
return@use BmlQrPayResult(false, errorMessage = "Parse error")
|
||||||
|
}
|
||||||
|
if (!json.optBoolean("success")) {
|
||||||
|
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
|
||||||
|
} else {
|
||||||
|
val payload = json.optJSONObject("payload")
|
||||||
|
BmlQrPayResult(
|
||||||
|
success = true,
|
||||||
|
merchant = payload?.optString("merchant") ?: "",
|
||||||
|
amount = payload?.optString("amount") ?: "",
|
||||||
|
currency = payload?.optString("currency") ?: currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.fahipay
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -32,8 +33,10 @@ class FahipayHistoryClient {
|
|||||||
.header("User-Agent", UA)
|
.header("User-Agent", UA)
|
||||||
.build()
|
.build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
resp.close()
|
resp.close()
|
||||||
|
if (code in 500..599) throw BankServerException("Fahipay")
|
||||||
return try {
|
return try {
|
||||||
val obj = JSONObject(json)
|
val obj = JSONObject(json)
|
||||||
val total = obj.optInt("total", 0)
|
val total = obj.optInt("total", 0)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import okhttp3.FormBody
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class MibHistoryClient {
|
class MibHistoryClient {
|
||||||
@@ -60,6 +61,7 @@ class MibHistoryClient {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
|
if (response.code in 500..599) throw BankServerException("MIB")
|
||||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.addInterceptor { chain ->
|
.addNetworkInterceptor { chain ->
|
||||||
val req = chain.request().newBuilder()
|
val req = chain.request().newBuilder()
|
||||||
.header("User-Agent", "android/1.0")
|
.header("User-Agent", "android/1.0")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
@@ -366,6 +366,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a profile image via P40.
|
||||||
|
* [imageBase64] is a base64-encoded JPEG string.
|
||||||
|
* Returns the new imageHash on success, or null on failure.
|
||||||
|
*/
|
||||||
|
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
||||||
|
val payload = baseData(session, "P40").apply {
|
||||||
|
put("profileId", profile.profileId)
|
||||||
|
put("profileImage", imageBase64)
|
||||||
|
}
|
||||||
|
val resp = doRequest(session, payload, "n")
|
||||||
|
if (!resp.optBoolean("success", false)) return null
|
||||||
|
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
||||||
|
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the profile image via P42.
|
||||||
|
* Returns true on success.
|
||||||
|
*/
|
||||||
|
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
||||||
|
val payload = baseData(session, "P42").apply {
|
||||||
|
put("profileId", profile.profileId)
|
||||||
|
}
|
||||||
|
val resp = doRequest(session, payload, "n")
|
||||||
|
return resp.optBoolean("success", false)
|
||||||
|
}
|
||||||
|
|
||||||
private fun post(body: FormBody): String {
|
private fun post(body: FormBody): String {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(BASE_URL)
|
.url(BASE_URL)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
@@ -149,8 +150,15 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
pendingIconUrls.clear()
|
pendingIconUrls.clear()
|
||||||
firstPageDone = false
|
firstPageDone = false
|
||||||
fetcher = HistoryFetcher(account)
|
fetcher = HistoryFetcher(account)
|
||||||
adapter.setTransactions(emptyList())
|
// Restore cache immediately so data stays visible while refreshing
|
||||||
binding.emptyView.visibility = View.GONE
|
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
allTransactions.addAll(cached)
|
||||||
|
filterAndDisplay()
|
||||||
|
} else {
|
||||||
|
adapter.setTransactions(emptyList())
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
}
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,9 +173,20 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val transactions = fetcher.fetchNextPage(app, pageSize)
|
val transactions = try {
|
||||||
|
fetcher.fetchNextPage(app, pageSize)
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
|
null
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
||||||
|
null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
|
||||||
if (!firstPageDone) {
|
if (!firstPageDone) {
|
||||||
firstPageDone = true
|
firstPageDone = true
|
||||||
@@ -175,6 +194,13 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.swipeRefresh.isRefreshing = false
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transactions == null) {
|
||||||
|
adapter.showLoadingFooter = false
|
||||||
|
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||||
|
|
||||||
if (transactions.isNotEmpty()) {
|
if (transactions.isNotEmpty()) {
|
||||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||||
val newOnes = transactions.filter { it.id !in existingIds }
|
val newOnes = transactions.filter { it.id !in existingIds }
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package sh.sar.basedbank.ui.home
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||||
@@ -17,7 +19,11 @@ import sh.sar.basedbank.util.AccountListParser
|
|||||||
|
|
||||||
class AccountsAdapter(
|
class AccountsAdapter(
|
||||||
accounts: List<BankAccount>,
|
accounts: List<BankAccount>,
|
||||||
private val onAccountClick: (BankAccount) -> Unit = {}
|
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||||
|
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||||
|
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||||
|
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||||
|
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
@@ -112,6 +118,8 @@ class AccountsAdapter(
|
|||||||
|
|
||||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
private var boundHash: String? = null
|
||||||
|
|
||||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||||
binding.tvAccountName.text = display.name
|
binding.tvAccountName.text = display.name
|
||||||
binding.tvAccountNumber.text = display.number
|
binding.tvAccountNumber.text = display.number
|
||||||
@@ -123,6 +131,30 @@ class AccountsAdapter(
|
|||||||
copyToClipboard(it.context, display.number)
|
copyToClipboard(it.context, display.number)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val staticLogo = when (account.bank) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||||
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||||
|
else binding.ivBankLogo.setImageDrawable(null)
|
||||||
|
|
||||||
|
val hash = account.profileImageHash
|
||||||
|
boundHash = hash
|
||||||
|
when {
|
||||||
|
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
|
||||||
|
profileImageLoader.invoke(hash) { bitmap ->
|
||||||
|
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
|
||||||
|
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
|
||||||
|
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -8,9 +11,15 @@ import androidx.core.view.ViewCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||||
|
import sh.sar.basedbank.util.ProfileImageStore
|
||||||
|
|
||||||
class AccountsFragment : Fragment() {
|
class AccountsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -18,6 +27,7 @@ class AccountsFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
private lateinit var adapter: AccountsAdapter
|
private lateinit var adapter: AccountsAdapter
|
||||||
|
private val profileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
||||||
@@ -25,9 +35,49 @@ class AccountsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
adapter = AccountsAdapter(emptyList()) { account ->
|
val app = requireActivity().application as BasedBankApp
|
||||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
adapter = AccountsAdapter(
|
||||||
}
|
accounts = emptyList(),
|
||||||
|
onAccountClick = { account ->
|
||||||
|
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||||
|
},
|
||||||
|
profileImageLoader = { hash, onLoaded ->
|
||||||
|
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val session = app.anyMibSession() ?: return@withContext null
|
||||||
|
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
|
||||||
|
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
profileImageCache[hash] = bitmap
|
||||||
|
onLoaded(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
||||||
|
val cacheKey = "$loginTag|$profileId"
|
||||||
|
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
||||||
|
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
||||||
|
} else if (loginTag.startsWith("fahipay_")) {
|
||||||
|
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
||||||
|
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
profileImageCache[cacheKey] = bitmap
|
||||||
|
onLoaded(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
adapter.onTransferClick = { account ->
|
adapter.onTransferClick = { account ->
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||||
}
|
}
|
||||||
@@ -43,7 +93,10 @@ class AccountsFragment : Fragment() {
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||||
|
adapter.updateAccounts(it)
|
||||||
|
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.BaseAdapter
|
||||||
|
import android.widget.Filter
|
||||||
|
import android.widget.Filterable
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
class BmlQrPayFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentBmlQrPayBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var merchantInfo: BmlQrPayInfo? = null
|
||||||
|
private var selectedAccount: BankAccount? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_QR_URL = "qr_url"
|
||||||
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
|
|
||||||
|
fun newInstance(qrUrl: String, fromAccountNumber: String? = null) = BmlQrPayFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_QR_URL, qrUrl)
|
||||||
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentBmlQrPayBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
setupFromDropdown()
|
||||||
|
|
||||||
|
binding.etAmount.addTextChangedListener { updatePayButton() }
|
||||||
|
|
||||||
|
binding.btnClearFromInfo.setOnClickListener {
|
||||||
|
selectedAccount = null
|
||||||
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
|
binding.actvFrom.setText("", false)
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnPay.setOnClickListener { initiatePay() }
|
||||||
|
|
||||||
|
val qrUrl = arguments?.getString(ARG_QR_URL) ?: run {
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed(); return
|
||||||
|
}
|
||||||
|
lookupMerchant(qrUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupFromDropdown() {
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
val bmlAccounts = accounts.filter {
|
||||||
|
it.bank == "BML" &&
|
||||||
|
it.profileType != "BML_LOAN" &&
|
||||||
|
it.profileType != "BML_CREDIT"
|
||||||
|
}
|
||||||
|
val adapter = BmlAccountAdapter(bmlAccounts)
|
||||||
|
binding.actvFrom.setAdapter(adapter)
|
||||||
|
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val picked = adapter.getItem(position) as? BankAccount ?: return@setOnItemClickListener
|
||||||
|
selectedAccount = picked
|
||||||
|
showFromCard(picked)
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-select card passed in from the card wallet/dashboard
|
||||||
|
val preselect = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||||
|
if (preselect != null && selectedAccount == null) {
|
||||||
|
bmlAccounts.firstOrNull { it.accountNumber == preselect }?.let {
|
||||||
|
selectedAccount = it
|
||||||
|
showFromCard(it)
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFromCard(account: BankAccount) {
|
||||||
|
binding.tvFromAccountName.text = account.accountBriefName
|
||||||
|
binding.tvFromAccountNumber.text = account.accountNumber
|
||||||
|
val currency = account.currencyName.ifBlank { "MVR" }
|
||||||
|
binding.tvFromBalance.text = "$currency ${account.availableBalance}"
|
||||||
|
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, "#0066A1"))
|
||||||
|
binding.tilFrom.visibility = View.GONE
|
||||||
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Update amount prefix to match account currency
|
||||||
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
|
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val session = app.anyBmlSession() ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvLookingUp.visibility = View.VISIBLE
|
||||||
|
binding.cardMerchant.visibility = View.GONE
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val info = withContext(Dispatchers.IO) {
|
||||||
|
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
binding.tvLookingUp.visibility = View.GONE
|
||||||
|
if (info == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
merchantInfo = info
|
||||||
|
populateMerchant(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateMerchant(info: BmlQrPayInfo) {
|
||||||
|
binding.tvMerchantName.text = info.merchantName
|
||||||
|
binding.tvMerchantAddress.text = info.merchantAddress
|
||||||
|
binding.ivMerchantIcon.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.cardMerchant.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Dynamic QR: pre-fill amount and lock the field
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePayButton() {
|
||||||
|
val merchant = merchantInfo ?: run { binding.btnPay.isEnabled = false; return }
|
||||||
|
val account = selectedAccount ?: run { binding.btnPay.isEnabled = false; return }
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
|
binding.btnPay.isEnabled = amount > 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initiatePay() {
|
||||||
|
val info = merchantInfo ?: return
|
||||||
|
val account = selectedAccount ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_qr_select_account, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
binding.tilAmount.error = "Enter a valid amount"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
|
||||||
|
val debitAccount = account.internalId.ifBlank {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currency = info.currency
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.transfer)
|
||||||
|
.setMessage("Pay $currency ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${account.accountBriefName} · ${account.accountNumber}")
|
||||||
|
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
||||||
|
executePay(account, debitAccount, info.requestId, amount, currency, info.merchantName)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executePay(
|
||||||
|
account: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String,
|
||||||
|
merchantName: String
|
||||||
|
) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val session = app.bmlSessionFor(account) ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: run {
|
||||||
|
Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnPay.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val initiated = BmlQrPayClient().initiatePayment(session, debitAccount, requestId, amount, currency)
|
||||||
|
if (!initiated) return@withContext null
|
||||||
|
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
BmlQrPayClient().confirmPayment(session, debitAccount, requestId, amount, currency, confirmOtp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
binding.btnPay.isEnabled = true
|
||||||
|
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showSuccessDialog(
|
||||||
|
merchant = result.merchant.ifBlank { merchantName },
|
||||||
|
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||||
|
currency = result.currency.ifBlank { currency }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
binding.btnPay.isEnabled = true
|
||||||
|
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Green checkmark icon
|
||||||
|
container.addView(ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_check_circle)
|
||||||
|
setColorFilter(Color.parseColor("#4CAF50"))
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
(64 * dp).toInt(), (64 * dp).toInt()
|
||||||
|
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Amount
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = "$currency $amount"
|
||||||
|
textSize = 28f
|
||||||
|
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||||
|
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merchant name
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = merchant
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = Gravity.CENTER_HORIZONTAL }
|
||||||
|
})
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.bml_qr_payment_success)
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||||
|
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
||||||
|
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||||
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
paint.color = bgColor
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = sizePx * 0.42f
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
val metrics = paint.fontMetrics
|
||||||
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = "BML QR Pay"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
|
||||||
|
BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
override fun getCount() = accounts.size
|
||||||
|
override fun getItem(position: Int) = accounts[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.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
|
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||||
|
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.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||||
|
"$prefix${it.accountBriefName}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ class ContactPickerAdapter(
|
|||||||
val isSameAsFrom: Boolean = false,
|
val isSameAsFrom: Boolean = false,
|
||||||
val isManualEntry: Boolean = false,
|
val isManualEntry: Boolean = false,
|
||||||
val imageHash: String? = null,
|
val imageHash: String? = null,
|
||||||
val inactiveReason: String? = null
|
val inactiveReason: String? = null,
|
||||||
|
val balance: String? = null,
|
||||||
|
val bankLogoRes: Int? = null
|
||||||
) : PickerItem()
|
) : PickerItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
|
|||||||
binding.tvPrimary.text = item.displayName
|
binding.tvPrimary.text = item.displayName
|
||||||
binding.tvSecondary.text = item.subtitle
|
binding.tvSecondary.text = item.subtitle
|
||||||
|
|
||||||
val cached = item.imageHash?.let { imageCache[it] }
|
if (item.balance != null) {
|
||||||
if (cached != null) {
|
binding.tvBalance.text = item.balance
|
||||||
binding.ivIcon.setImageBitmap(cached)
|
binding.tvBalance.visibility = android.view.View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
binding.tvBalance.visibility = android.view.View.GONE
|
||||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
}
|
||||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
|
||||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
val cached = item.imageHash?.let { imageCache[it] }
|
||||||
|
when {
|
||||||
|
cached != null -> {
|
||||||
|
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivIcon.setImageBitmap(cached)
|
||||||
|
}
|
||||||
|
item.bankLogoRes != null -> {
|
||||||
|
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivIcon.setImageResource(item.bankLogoRes)
|
||||||
|
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||||
|
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||||
|
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
import sh.sar.basedbank.util.ProfileImageStore
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
|
|
||||||
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
@@ -147,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
|
||||||
(activity as? HomeActivity)?.loadAllContacts()
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachMediator(pages: List<TabDef>) {
|
private fun attachMediator(pages: List<TabDef>) {
|
||||||
@@ -225,17 +229,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
for (acc in filteredRegular) {
|
for (acc in filteredRegular) {
|
||||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||||
val isSame = acc.accountNumber == fromAccountNumber
|
val isSame = acc.accountNumber == fromAccountNumber
|
||||||
val accBal = if (hide) "••••••" else acc.availableBalance
|
val parsedBalance = AccountListParser.from(acc)?.balance
|
||||||
|
?: "${acc.currencyName} ${acc.availableBalance}"
|
||||||
|
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
|
||||||
|
val logoRes = when (acc.bank) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||||
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val localKey = localImageKeyFor(acc)
|
||||||
|
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||||
accountNumber = acc.accountNumber,
|
accountNumber = acc.accountNumber,
|
||||||
displayName = acc.accountBriefName,
|
displayName = acc.accountBriefName,
|
||||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} $accBal",
|
subtitle = acc.accountNumber,
|
||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash,
|
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = if (isSame) null
|
||||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||||
|
balance = balance,
|
||||||
|
bankLogoRes = logoRes
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,18 +265,26 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||||
val isSame = acc.accountNumber == fromAccountNumber
|
val isSame = acc.accountNumber == fromAccountNumber
|
||||||
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
||||||
val cardBal = if (hide) "••••••" else acc.availableBalance
|
val isDebit = acc.profileType == "BML_DEBIT"
|
||||||
|
val parsedBalance = if (isDebit) null
|
||||||
|
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
|
||||||
|
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
|
||||||
|
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
|
||||||
|
val localKey = localImageKeyFor(acc)
|
||||||
|
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||||
accountNumber = acc.accountNumber,
|
accountNumber = acc.accountNumber,
|
||||||
displayName = acc.accountBriefName,
|
displayName = acc.accountBriefName,
|
||||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} $cardBal",
|
subtitle = acc.accountNumber,
|
||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash,
|
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = if (isSame) null
|
||||||
else if (!isActive) acc.statusDesc
|
else if (!isActive) acc.statusDesc
|
||||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||||
|
balance = balance,
|
||||||
|
bankLogoRes = logoRes
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,6 +315,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun fetchImage(hash: String) {
|
private fun fetchImage(hash: String) {
|
||||||
if (!pendingHashes.add(hash)) return
|
if (!pendingHashes.add(hash)) return
|
||||||
|
// Local image keys for BML/Fahipay (prefixed with "local:")
|
||||||
|
if (hash.startsWith("local:")) {
|
||||||
|
val key = hash.removePrefix("local:")
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
|
||||||
|
pendingHashes.remove(hash); return@launch
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
val sess = session ?: return
|
val sess = session ?: return
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -318,6 +353,16 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
||||||
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
||||||
|
|
||||||
|
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
|
||||||
|
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
|
||||||
|
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
|
||||||
|
"FAHIPAY" -> {
|
||||||
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||||
|
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ class ContactsFragment : Fragment() {
|
|||||||
private var currentSearch: String = ""
|
private var currentSearch: String = ""
|
||||||
private var mediator: TabLayoutMediator? = null
|
private var mediator: TabLayoutMediator? = null
|
||||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||||
private var contactsRefreshing = false
|
|
||||||
|
|
||||||
private data class TabPage(val categoryId: String?, val label: String)
|
private data class TabPage(val categoryId: String?, val label: String)
|
||||||
|
|
||||||
@@ -136,8 +135,8 @@ class ContactsFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.loadAllContacts()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
contactsRefreshing = true
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
(activity as? HomeActivity)?.loadAllContacts()
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||||
@@ -149,10 +148,6 @@ class ContactsFragment : Fragment() {
|
|||||||
pagerAdapter.updateContacts(allContacts)
|
pagerAdapter.updateContacts(allContacts)
|
||||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
if (contactsRefreshing) {
|
|
||||||
contactsRefreshing = false
|
|
||||||
binding.swipeRefresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -8,6 +10,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -32,6 +35,22 @@ class DashboardFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var pendingQrAccountNumber: String? = 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
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||||
|
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
pendingQrAccountNumber = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -53,6 +72,10 @@ class DashboardFragment : Fragment() {
|
|||||||
binding.swipeRefresh.isRefreshing = false
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.cardPendingFinances.setOnClickListener {
|
||||||
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||||
|
}
|
||||||
|
|
||||||
val cardAdapter = DashboardCardAdapter()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = cardAdapter
|
binding.rvCards.adapter = cardAdapter
|
||||||
@@ -277,18 +300,21 @@ class DashboardFragment : Fragment() {
|
|||||||
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val isMib = item is CardItem.Mib
|
||||||
btnPayQr.setOnClickListener {
|
btnPayQr.setOnClickListener {
|
||||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
if (isMib) {
|
||||||
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
btnPayNfc.isEnabled = nfcSupported
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
if (nfcSupported) {
|
btnPayNfc.setOnClickListener {
|
||||||
btnPayNfc.setOnClickListener {
|
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||||
}
|
|
||||||
} else {
|
|
||||||
btnPayNfc.setOnClickListener(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class FinancingFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
private lateinit var adapter: FinancingAdapter
|
private lateinit var adapter: FinancingAdapter
|
||||||
private var financingRefreshing = false
|
|
||||||
|
|
||||||
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||||
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||||
@@ -46,8 +45,8 @@ class FinancingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
financingRefreshing = true
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
(activity as? HomeActivity)?.triggerRefreshFinancing()
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||||
@@ -74,10 +73,6 @@ class FinancingFragment : Fragment() {
|
|||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
if (financingRefreshing) {
|
|
||||||
financingRefreshing = false
|
|
||||||
binding.swipeRefresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -549,12 +549,12 @@ fun applyNavLabelVisibility() {
|
|||||||
autoRefresh(store)
|
autoRefresh(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showConnectivityBanner(message: String) {
|
fun showConnectivityBanner(message: String) {
|
||||||
binding.connectivityBanner.text = message
|
binding.connectivityBanner.text = message
|
||||||
binding.connectivityBanner.visibility = View.VISIBLE
|
binding.connectivityBanner.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideConnectivityBanner() {
|
fun hideConnectivityBanner() {
|
||||||
binding.connectivityBanner.visibility = View.GONE
|
binding.connectivityBanner.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ object NavCustomization {
|
|||||||
|
|
||||||
data class NavItemDef(
|
data class NavItemDef(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
val key: String,
|
||||||
@DrawableRes val iconRes: Int,
|
@DrawableRes val iconRes: Int,
|
||||||
@StringRes val titleRes: Int,
|
@StringRes val titleRes: Int,
|
||||||
@StringRes val descriptionRes: Int
|
@StringRes val descriptionRes: Int
|
||||||
@@ -16,42 +17,48 @@ object NavCustomization {
|
|||||||
|
|
||||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
val ALL_SWAPPABLE = listOf(
|
val ALL_SWAPPABLE = listOf(
|
||||||
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||||
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||||
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||||
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||||
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
NavItemDef(R.id.nav_activities, "nav_activities", R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
||||||
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
NavItemDef(R.id.nav_transfer_history, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
||||||
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
NavItemDef(R.id.nav_finances, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||||
NavItemDef(R.id.nav_pay_with_card, R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
||||||
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
NavItemDef(R.id.nav_card_settings, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
||||||
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||||
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
NavItemDef(R.id.nav_settings, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun keyToId(key: String?, default: Int) =
|
||||||
|
ALL_SWAPPABLE.find { it.key == key }?.id ?: default
|
||||||
|
|
||||||
|
private fun idToKey(id: Int) =
|
||||||
|
ALL_SWAPPABLE.find { it.id == id }?.key
|
||||||
|
|
||||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
|
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
||||||
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
|
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
|
||||||
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
|
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putInt("bottom_nav_slot_1", slots[0])
|
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
||||||
.putInt("bottom_nav_slot_2", slots[1])
|
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
|
||||||
.putInt("bottom_nav_slot_3", slots[2])
|
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
prefs.getInt("quick_action_1", R.id.nav_transfer),
|
keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
|
||||||
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
|
keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putInt("quick_action_1", ids[0])
|
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
|
||||||
.putInt("quick_action_2", ids[1])
|
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
@@ -49,10 +53,19 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private var selectedAccount: BankAccount? = null
|
private var selectedAccount: BankAccount? = null
|
||||||
private var generatedBitmap: Bitmap? = null
|
private var generatedBitmap: Bitmap? = null
|
||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
// BML card/gateway QR — hand off to dedicated payment screen
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||||
|
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
if (qr == null || qr.accountNumber == null) {
|
if (qr == null || qr.accountNumber == null) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
@@ -402,9 +415,105 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
|
||||||
|
val displayData = AccountListParser.from(acc)
|
||||||
|
val typeLabel = displayData?.typeLabel
|
||||||
|
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||||
|
else acc.accountTypeName.trim()
|
||||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
b.tvDropdownBalance.text = ""
|
if (typeLabel.isNotBlank()) {
|
||||||
|
b.tvDropdownAccountType.text = typeLabel
|
||||||
|
b.tvDropdownAccountType.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
b.tvDropdownAccountType.visibility = View.GONE
|
||||||
|
}
|
||||||
|
b.tvDropdownBalance.text = displayData?.balance ?: ""
|
||||||
b.root.alpha = 1f
|
b.root.alpha = 1f
|
||||||
|
|
||||||
|
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||||
|
when {
|
||||||
|
networkIcon != null -> {
|
||||||
|
b.ivDropdownCardLogo.setImageResource(networkIcon)
|
||||||
|
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
acc.bank == "BML" -> {
|
||||||
|
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||||
|
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||||
|
val imageView = b.ivDropdownCardLogo
|
||||||
|
imageView.tag = localKey
|
||||||
|
if (cachedLocal != null) {
|
||||||
|
imageView.setImageBitmap(cachedLocal)
|
||||||
|
} else {
|
||||||
|
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||||
|
if (acc.profileId.isNotBlank()) {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
dropdownProfileImageCache[localKey] = bitmap
|
||||||
|
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
acc.bank == "FAHIPAY" -> {
|
||||||
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||||
|
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||||
|
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||||
|
val imageView = b.ivDropdownCardLogo
|
||||||
|
imageView.tag = localKey
|
||||||
|
if (cachedLocal != null) {
|
||||||
|
imageView.setImageBitmap(cachedLocal)
|
||||||
|
} else {
|
||||||
|
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||||
|
if (loginId.isNotBlank()) {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
dropdownProfileImageCache[localKey] = bitmap
|
||||||
|
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
acc.bank == "MIB" -> {
|
||||||
|
val hash = acc.profileImageHash
|
||||||
|
val cached = hash?.let { dropdownProfileImageCache[it] }
|
||||||
|
val imageView = b.ivDropdownCardLogo
|
||||||
|
imageView.tag = hash
|
||||||
|
if (cached != null) {
|
||||||
|
imageView.setImageBitmap(cached)
|
||||||
|
} else {
|
||||||
|
imageView.setImageResource(R.drawable.mib_logo)
|
||||||
|
if (hash != null) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val sess = app.anyMibSession() ?: return@withContext null
|
||||||
|
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
|
||||||
|
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
dropdownProfileImageCache[hash] = bitmap
|
||||||
|
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
}
|
||||||
return b.root
|
return b.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -10,6 +12,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
@@ -29,6 +32,22 @@ class PayWithCardFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var pendingQrAccountNumber: String? = 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
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||||
|
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
pendingQrAccountNumber = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -131,18 +150,21 @@ class PayWithCardFragment : Fragment() {
|
|||||||
bindCardStatus(tvCardStatus, bmlStatus)
|
bindCardStatus(tvCardStatus, bmlStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val isMib = item is CardItem.Mib
|
||||||
btnPayQr.setOnClickListener {
|
btnPayQr.setOnClickListener {
|
||||||
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
if (isMib) {
|
||||||
|
Toast.makeText(context, R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
btnPayNfc.isEnabled = nfcSupported
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
if (nfcSupported) {
|
btnPayNfc.setOnClickListener {
|
||||||
btnPayNfc.setOnClickListener {
|
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||||
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
}
|
|
||||||
} else {
|
|
||||||
btnPayNfc.setOnClickListener(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import android.provider.Settings
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.view.ScaleGestureDetector
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.camera.core.Camera
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -52,6 +58,8 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
textMode = ZxingCpp.TextMode.PLAIN
|
textMode = ZxingCpp.TextMode.PLAIN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private var camera: Camera? = null
|
||||||
|
private var torchEnabled = false
|
||||||
private var cameraStarted = false
|
private var cameraStarted = false
|
||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
@@ -104,8 +112,36 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
binding.btnCancel.setOnClickListener { finish() }
|
|
||||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||||
|
binding.zoomSlider.addOnChangeListener { _, value, fromUser ->
|
||||||
|
if (fromUser) camera?.cameraControl?.setLinearZoom(value)
|
||||||
|
}
|
||||||
|
val scaleDetector = ScaleGestureDetector(this,
|
||||||
|
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||||
|
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||||
|
val state = camera?.cameraInfo?.zoomState?.value ?: return true
|
||||||
|
camera?.cameraControl?.setZoomRatio(
|
||||||
|
(state.zoomRatio * detector.scaleFactor)
|
||||||
|
.coerceIn(state.minZoomRatio, state.maxZoomRatio)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
binding.previewView.setOnTouchListener { _, event ->
|
||||||
|
scaleDetector.onTouchEvent(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
binding.btnFlashlight.setOnClickListener {
|
||||||
|
torchEnabled = !torchEnabled
|
||||||
|
camera?.cameraControl?.enableTorch(torchEnabled)
|
||||||
|
val drawableRes = if (torchEnabled) R.drawable.ic_flashlight_to_on else R.drawable.ic_flashlight_to_off
|
||||||
|
val drawable = AppCompatResources.getDrawable(this, drawableRes)
|
||||||
|
binding.btnFlashlight.icon = drawable
|
||||||
|
(drawable as? Animatable)?.start()
|
||||||
|
binding.btnFlashlight.iconTint = ColorStateList.valueOf(
|
||||||
|
if (torchEnabled) Color.parseColor("#FFEB3B") else Color.WHITE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
== PackageManager.PERMISSION_GRANTED
|
== PackageManager.PERMISSION_GRANTED
|
||||||
@@ -179,9 +215,12 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
camera = provider.bindToLifecycle(
|
||||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||||
)
|
)
|
||||||
|
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
|
||||||
|
binding.zoomSlider.value = state.linearZoom
|
||||||
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package sh.sar.basedbank.ui.home
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -10,6 +14,8 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -22,11 +28,13 @@ import sh.sar.basedbank.api.mib.TransactionCache
|
|||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
|
import sh.sar.basedbank.util.CardsCache
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
|
import sh.sar.basedbank.util.ProfileImageStore
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -37,6 +45,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class SettingsLoginsFragment : Fragment() {
|
class SettingsLoginsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -44,6 +54,248 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
// ── Profile image picker state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class PendingImageTarget {
|
||||||
|
data class Mib(val loginId: String, val profile: MibProfile) : PendingImageTarget()
|
||||||
|
data class Bml(val profileId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||||
|
data class Fahipay(val loginId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||||
|
}
|
||||||
|
private var pendingImageTarget: PendingImageTarget? = null
|
||||||
|
private var cameraPhotoUri: Uri? = null
|
||||||
|
|
||||||
|
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||||
|
handlePickedImage(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||||
|
if (!success) return@registerForActivityResult
|
||||||
|
val uri = cameraPhotoUri ?: return@registerForActivityResult
|
||||||
|
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||||
|
handlePickedImage(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val inputStream = ctx.contentResolver.openInputStream(uri) ?: return null
|
||||||
|
val original = BitmapFactory.decodeStream(inputStream)
|
||||||
|
inputStream.close()
|
||||||
|
if (original == null) return null
|
||||||
|
val maxDim = 512
|
||||||
|
val scale = minOf(maxDim.toFloat() / original.width, maxDim.toFloat() / original.height, 1f)
|
||||||
|
if (scale < 1f) {
|
||||||
|
Bitmap.createScaledBitmap(original, (original.width * scale).toInt(), (original.height * scale).toInt(), true)
|
||||||
|
} else original
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePickedImage(bitmap: Bitmap) {
|
||||||
|
val target = pendingImageTarget ?: return
|
||||||
|
when (target) {
|
||||||
|
is PendingImageTarget.Mib -> uploadMibProfileImage(target.loginId, target.profile, bitmap)
|
||||||
|
is PendingImageTarget.Bml -> {
|
||||||
|
ProfileImageStore.save(requireContext(), ProfileImageStore.bmlKey(target.profileId), bitmap)
|
||||||
|
target.refreshImage(bitmap)
|
||||||
|
}
|
||||||
|
is PendingImageTarget.Fahipay -> {
|
||||||
|
ProfileImageStore.save(requireContext(), ProfileImageStore.fahipayKey(target.loginId), bitmap)
|
||||||
|
target.refreshImage(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadMibProfileImage(loginId: String, profile: MibProfile, bitmap: Bitmap) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val progress = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setMessage(getString(R.string.profile_image_uploading))
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val session = app.mibSessions[loginId] ?: app.anyMibSession() ?: return@withContext null
|
||||||
|
val flow = app.mibLoginFlows[loginId] ?: return@withContext null
|
||||||
|
flow.switchProfile(session, profile)
|
||||||
|
// MIB server enforces a small payload limit (~4KB); scale to 100px max
|
||||||
|
val mibMax = 100
|
||||||
|
val mibScale = minOf(mibMax.toFloat() / bitmap.width, mibMax.toFloat() / bitmap.height, 1f)
|
||||||
|
val mibBitmap = if (mibScale < 1f)
|
||||||
|
Bitmap.createScaledBitmap(bitmap, (bitmap.width * mibScale).toInt(), (bitmap.height * mibScale).toInt(), true)
|
||||||
|
else bitmap
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
mibBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos)
|
||||||
|
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||||
|
flow.uploadProfileImage(session, profile, base64)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
progress.dismiss()
|
||||||
|
if (result == null) {
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setMessage(getString(R.string.profile_image_upload_failed))
|
||||||
|
.setPositiveButton(R.string.close, null)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showImagePickerMenu(anchor: View, target: PendingImageTarget, currentBitmap: Bitmap?) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
|
||||||
|
val items = mutableListOf<Triple<Int, String, () -> Unit>>()
|
||||||
|
items += Triple(R.drawable.ic_image, getString(R.string.profile_image_select)) {
|
||||||
|
pendingImageTarget = target
|
||||||
|
galleryLauncher.launch("image/*")
|
||||||
|
}
|
||||||
|
items += Triple(R.drawable.ic_camera, getString(R.string.profile_image_camera)) {
|
||||||
|
pendingImageTarget = target
|
||||||
|
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
||||||
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
||||||
|
cameraPhotoUri = uri
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
}
|
||||||
|
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
||||||
|
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
||||||
|
removeProfileImage(ctx, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val vp = (8 * dp).toInt()
|
||||||
|
setPadding(0, vp, 0, vp)
|
||||||
|
}
|
||||||
|
for ((iconRes, label, action) in items) {
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
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 vp2 = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp2, hp, vp2)
|
||||||
|
}
|
||||||
|
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||||
|
val icon = ImageView(ctx).apply {
|
||||||
|
setImageResource(iconRes)
|
||||||
|
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
||||||
|
}
|
||||||
|
val tv = TextView(ctx).apply {
|
||||||
|
text = label
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(icon, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(tv)
|
||||||
|
list.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.profile_image_title)
|
||||||
|
.setView(list)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val rows = (0 until list.childCount).map { list.getChildAt(it) }
|
||||||
|
rows.forEachIndexed { i, row ->
|
||||||
|
row.setOnClickListener {
|
||||||
|
dialog.dismiss()
|
||||||
|
items[i].third()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasSavedImage(ctx: Context, target: PendingImageTarget): Boolean = when (target) {
|
||||||
|
is PendingImageTarget.Mib -> target.profile.customerImage != null
|
||||||
|
is PendingImageTarget.Bml -> ProfileImageStore.exists(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||||
|
is PendingImageTarget.Fahipay -> ProfileImageStore.exists(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeProfileImage(ctx: Context, target: PendingImageTarget) {
|
||||||
|
when (target) {
|
||||||
|
is PendingImageTarget.Mib -> {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val progress = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setMessage(getString(R.string.profile_image_deleting))
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val session = app.mibSessions[target.loginId] ?: app.anyMibSession() ?: return@withContext
|
||||||
|
val flow = app.mibLoginFlows[target.loginId] ?: return@withContext
|
||||||
|
flow.switchProfile(session, target.profile)
|
||||||
|
flow.deleteProfileImage(session, target.profile)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
progress.dismiss()
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PendingImageTarget.Bml -> {
|
||||||
|
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||||
|
target.refreshImage(null)
|
||||||
|
}
|
||||||
|
is PendingImageTarget.Fahipay -> {
|
||||||
|
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||||
|
target.refreshImage(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makePencilButton(ctx: Context): ImageView {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val size = (32 * dp).toInt()
|
||||||
|
return ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_edit)
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeCircleAvatarView(ctx: Context, sizeDp: Int): ImageView {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val size = (sizeDp * dp).toInt()
|
||||||
|
return ImageView(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
clipToOutline = true
|
||||||
|
outlineProvider = object : android.view.ViewOutlineProvider() {
|
||||||
|
override fun getOutline(view: View, outline: android.graphics.Outline) {
|
||||||
|
outline.setOval(0, 0, view.width, view.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAvatarBitmap(iv: ImageView, bitmap: Bitmap?) {
|
||||||
|
if (bitmap != null) {
|
||||||
|
iv.setImageBitmap(bitmap)
|
||||||
|
iv.imageTintList = null
|
||||||
|
} else {
|
||||||
|
iv.setImageResource(R.drawable.ic_image)
|
||||||
|
val color = com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
iv, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||||
|
iv.imageTintList = android.content.res.ColorStateList.valueOf(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -100,18 +352,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
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)}: ${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) } }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,8 +459,21 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
alpha = 0.6f
|
alpha = 0.6f
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
val pencil = makePencilButton(ctx).apply {
|
||||||
|
alpha = 0.38f
|
||||||
|
isEnabled = false
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = p.profileId !in hidden
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pencil.setOnClickListener {
|
||||||
|
android.widget.Toast.makeText(ctx, "Work in progress", android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
row.addView(textCol)
|
row.addView(textCol)
|
||||||
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
p to toggle
|
p to toggle
|
||||||
@@ -327,6 +581,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val toggleRows = bmlProfiles.map { p ->
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
|
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||||
|
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
|
setAvatarBitmap(avatarIv, currentBitmap)
|
||||||
|
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
@@ -349,8 +607,24 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
alpha = 0.6f
|
alpha = 0.6f
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
val pencil = makePencilButton(ctx)
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = p.profileId !in hidden
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val target = PendingImageTarget.Bml(p.profileId) { newBitmap ->
|
||||||
|
setAvatarBitmap(avatarIv, newBitmap)
|
||||||
|
}
|
||||||
|
pencil.setOnClickListener {
|
||||||
|
val cur = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
|
showImagePickerMenu(pencil, target, cur)
|
||||||
|
}
|
||||||
|
val avatarSize = (36 * dp).toInt()
|
||||||
|
row.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||||
row.addView(textCol)
|
row.addView(textCol)
|
||||||
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
p to toggle
|
p to toggle
|
||||||
@@ -614,6 +888,75 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showFahipayLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.FahipayUserProfile?
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val masked = "••••••"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Avatar row with pencil
|
||||||
|
val avatarRow = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val avatarSize = (56 * dp).toInt()
|
||||||
|
val avatarIv = makeCircleAvatarView(ctx, 56)
|
||||||
|
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||||
|
setAvatarBitmap(avatarIv, currentBitmap)
|
||||||
|
|
||||||
|
val pencil = makePencilButton(ctx)
|
||||||
|
val target = PendingImageTarget.Fahipay(loginId) { newBitmap ->
|
||||||
|
setAvatarBitmap(avatarIv, newBitmap)
|
||||||
|
}
|
||||||
|
pencil.setOnClickListener {
|
||||||
|
val cur = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||||
|
showImagePickerMenu(pencil, target, cur)
|
||||||
|
}
|
||||||
|
avatarRow.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||||
|
avatarRow.addView(pencil)
|
||||||
|
container.addView(avatarRow)
|
||||||
|
|
||||||
|
// Account info lines
|
||||||
|
listOfNotNull(
|
||||||
|
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||||
|
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||||
|
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
||||||
|
profile?.nid?.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).apply {
|
||||||
|
bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.fahipay_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.setPositiveButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -642,6 +985,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
app.mibLoginFlows.remove(loginId)
|
app.mibLoginFlows.remove(loginId)
|
||||||
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
|
viewModel.financing.value = emptyList()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
@@ -652,12 +996,17 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
// Remove all per-profile sessions for this login from the in-memory map
|
// Remove all per-profile sessions for this login from the in-memory map
|
||||||
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||||
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
profiles.forEach { p ->
|
||||||
|
app.bmlSessions.remove(p.profileId)
|
||||||
|
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
|
}
|
||||||
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||||
store.clearBmlCredentials(loginId)
|
store.clearBmlCredentials(loginId)
|
||||||
app.bmlProfilesMap.remove(loginId)
|
app.bmlProfilesMap.remove(loginId)
|
||||||
app.bmlLoginFlows.remove(loginId)
|
app.bmlLoginFlows.remove(loginId)
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||||
|
viewModel.bmlLimits.value = emptyList()
|
||||||
|
viewModel.bmlLoanDetails.value = emptyMap()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
@@ -665,6 +1014,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
|
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||||
store.clearFahipayCredentials(loginId)
|
store.clearFahipayCredentials(loginId)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.fahipaySessions.remove(loginId)
|
app.fahipaySessions.remove(loginId)
|
||||||
@@ -677,6 +1027,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
private fun clearAllCaches(ctx: Context) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||||
|
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
|
import sh.sar.basedbank.util.CardsCache
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
import sh.sar.basedbank.util.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
@@ -21,6 +23,7 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
|
|
||||||
private var _binding: FragmentSettingsStorageBinding? = null
|
private var _binding: FragmentSettingsStorageBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
||||||
@@ -32,6 +35,7 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +52,13 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
private fun clearAllCaches(ctx: Context) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||||
|
viewModel.accounts.value = emptyList()
|
||||||
|
viewModel.mibCards.value = null
|
||||||
|
viewModel.financing.value = emptyList()
|
||||||
|
viewModel.bmlLoanDetails.value = emptyMap()
|
||||||
|
viewModel.bmlLimits.value = emptyList()
|
||||||
|
viewModel.contacts.value = emptyList()
|
||||||
|
viewModel.contactCategories.value = emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
@@ -59,6 +61,8 @@ import sh.sar.basedbank.util.AccountListParser
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.ReceiptStore
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
@@ -82,11 +86,17 @@ class TransferFragment : Fragment() {
|
|||||||
private var resolvedAccountNumber = ""
|
private var resolvedAccountNumber = ""
|
||||||
private var resolvedRecipientName = ""
|
private var resolvedRecipientName = ""
|
||||||
private var resolvedBankName = ""
|
private var resolvedBankName = ""
|
||||||
|
private var resolvedToOwnAccount: BankAccount? = null
|
||||||
|
|
||||||
// Selected Fahipay service when source is Fahipay and destination is a phone number
|
// Selected Fahipay service when source is Fahipay and destination is a phone number
|
||||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
private var selectedFahipayService: String? = null
|
private var selectedFahipayService: String? = null
|
||||||
|
|
||||||
|
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
||||||
|
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||||
|
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
||||||
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
// BML business profile OTP flow state
|
// BML business profile OTP flow state
|
||||||
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
private var bmlOtpState = BmlOtpState.NONE
|
private var bmlOtpState = BmlOtpState.NONE
|
||||||
@@ -113,6 +123,14 @@ class TransferFragment : Fragment() {
|
|||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
// BML card/gateway QR — hand off to dedicated payment screen
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||||
|
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
if (qr == null || qr.accountNumber == null) {
|
if (qr == null || qr.accountNumber == null) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
@@ -132,6 +150,14 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
|
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||||
|
|
||||||
|
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_BML_QR_URL, qrUrl)
|
||||||
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
@@ -182,6 +208,7 @@ class TransferFragment : Fragment() {
|
|||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
accountDropdownAdapter?.notifyDataSetChanged()
|
accountDropdownAdapter?.notifyDataSetChanged()
|
||||||
selectedAccount?.let { showFromCard(it) }
|
selectedAccount?.let { showFromCard(it) }
|
||||||
|
resolvedToOwnAccount?.let { showToCard(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||||
@@ -224,6 +251,58 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
|
||||||
|
arguments?.getString(ARG_BML_QR_URL)?.let { lookupBmlQrMerchant(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookupBmlQrMerchant(qrUrl: String) {
|
||||||
|
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
||||||
|
val base64Url = android.util.Base64.encodeToString(
|
||||||
|
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
|
||||||
|
// Lock the "To" input row while loading
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val info = withContext(Dispatchers.IO) {
|
||||||
|
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
if (info == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
bmlQrInfo = info
|
||||||
|
|
||||||
|
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||||
|
binding.tvToAccountName.text = info.merchantName
|
||||||
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.btnClearToInfo.visibility = View.GONE
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill amount if dynamic QR
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remarks not applicable for merchant QR payments
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLookupLoading() {
|
private fun startLookupLoading() {
|
||||||
@@ -293,27 +372,102 @@ class TransferFragment : Fragment() {
|
|||||||
val bankLabel = when (account.bank) {
|
val bankLabel = when (account.bank) {
|
||||||
"BML" -> "BML"
|
"BML" -> "BML"
|
||||||
"FAHIPAY" -> "FP"
|
"FAHIPAY" -> "FP"
|
||||||
|
"MIB" -> "MIB"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val typeLabel = when {
|
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||||
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
?: if (account.bank == "BML") BmlDashboardParser.productLabel(account.accountTypeName)
|
||||||
account.profileType == "BML_CREDIT" -> "Credit Card"
|
else account.accountTypeName.ifBlank { account.profileType }
|
||||||
account.profileType == "BML_DEBIT" -> "Debit Card"
|
|
||||||
account.accountTypeName.isNotBlank() -> account.accountTypeName
|
|
||||||
else -> account.profileType
|
|
||||||
}
|
|
||||||
|
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val balancePart = "${account.currencyName} ${account.availableBalance}"
|
val isDebitCard = account.profileType == "BML_DEBIT"
|
||||||
|
val balancePart = if (isDebitCard) null else "${account.currencyName} ${account.availableBalance}"
|
||||||
binding.tvFromAccountName.text = account.accountBriefName
|
binding.tvFromAccountName.text = account.accountBriefName
|
||||||
binding.tvFromAccountNumber.text = account.accountNumber
|
binding.tvFromAccountNumber.text = account.accountNumber
|
||||||
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, if (hide) maskAmount(balancePart) else balancePart).joinToString(" · ")
|
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
|
||||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
|
||||||
|
if (balanceDisplay != null) {
|
||||||
|
binding.tvFromBalance.text = balanceDisplay
|
||||||
|
binding.tvFromBalance.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.tvFromBalance.visibility = View.GONE
|
||||||
|
}
|
||||||
|
val networkIcon = BmlCardParser.cardNetworkIcon(account)
|
||||||
|
when {
|
||||||
|
networkIcon != null -> {
|
||||||
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivFromPhoto.setImageResource(networkIcon)
|
||||||
|
}
|
||||||
|
account.bank == "BML" -> {
|
||||||
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivFromPhoto.setImageResource(R.drawable.bml_logo_vector)
|
||||||
|
}
|
||||||
|
account.bank == "FAHIPAY" -> {
|
||||||
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
|
||||||
|
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
binding.tilFrom.visibility = View.GONE
|
binding.tilFrom.visibility = View.GONE
|
||||||
binding.cardFromInfo.visibility = View.VISIBLE
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
if (account.bank != "BML" && account.profileImageHash != null) {
|
private fun showToCard(account: BankAccount) {
|
||||||
loadFromPhoto(account.profileImageHash)
|
resolvedToOwnAccount = account
|
||||||
|
val colorHex = when (account.bank) {
|
||||||
|
"BML" -> "#0066A1"
|
||||||
|
"FAHIPAY" -> "#15BEA7"
|
||||||
|
else -> "#FE860E"
|
||||||
|
}
|
||||||
|
val bankLabel = when (account.bank) {
|
||||||
|
"BML" -> "BML"
|
||||||
|
"FAHIPAY" -> "FP"
|
||||||
|
"MIB" -> "MIB"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||||
|
?: if (account.bank == "BML") BmlDashboardParser.productLabel(account.accountTypeName)
|
||||||
|
else account.accountTypeName.ifBlank { account.profileType }
|
||||||
|
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val isDebitCard = account.profileType == "BML_DEBIT"
|
||||||
|
val balancePart = if (isDebitCard) null else AccountListParser.from(account)?.balance
|
||||||
|
?: "${account.currencyName} ${account.availableBalance}"
|
||||||
|
|
||||||
|
binding.tvToAccountName.text = account.accountBriefName
|
||||||
|
binding.tvToBankBic.text = account.accountNumber
|
||||||
|
binding.tvToAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
|
||||||
|
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||||
|
val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
|
||||||
|
if (balanceDisplay != null) {
|
||||||
|
binding.tvToBalance.text = balanceDisplay
|
||||||
|
binding.tvToBalance.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
}
|
||||||
|
val networkIcon = BmlCardParser.cardNetworkIcon(account)
|
||||||
|
when {
|
||||||
|
networkIcon != null -> {
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(networkIcon)
|
||||||
|
}
|
||||||
|
account.bank == "BML" -> {
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.bml_logo_vector)
|
||||||
|
}
|
||||||
|
account.bank == "FAHIPAY" -> {
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
|
||||||
|
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +480,10 @@ class TransferFragment : Fragment() {
|
|||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (_binding != null) binding.ivFromPhoto.setImageBitmap(bitmap)
|
if (_binding != null) {
|
||||||
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivFromPhoto.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
@@ -342,6 +499,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnClearToInfo.setOnClickListener {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
|
resolvedToOwnAccount = null
|
||||||
selectedFahipayService = null
|
selectedFahipayService = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
@@ -357,6 +515,7 @@ class TransferFragment : Fragment() {
|
|||||||
if (binding.cardToInfo.visibility == View.VISIBLE) {
|
if (binding.cardToInfo.visibility == View.VISIBLE) {
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
|
resolvedToOwnAccount = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
@@ -466,9 +625,16 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedRecipientName = info.accountName
|
resolvedRecipientName = info.accountName
|
||||||
resolvedBankName = info.bankId
|
resolvedBankName = info.bankId
|
||||||
|
|
||||||
binding.tvToAccountName.text = displayName
|
if (matchedAcc != null) {
|
||||||
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
|
showToCard(matchedAcc)
|
||||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
} else {
|
||||||
|
binding.tvToAccountName.text = displayName
|
||||||
|
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
||||||
|
}
|
||||||
binding.tilTo.visibility = View.GONE
|
binding.tilTo.visibility = View.GONE
|
||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
@@ -593,9 +759,18 @@ class TransferFragment : Fragment() {
|
|||||||
val contacts = viewModel.contacts.value ?: emptyList()
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
||||||
|
|
||||||
binding.tvToAccountName.text = displayName
|
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == accountNumber }
|
||||||
binding.tvToBankBic.text = subtitle
|
if (ownAccount != null) {
|
||||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
showToCard(ownAccount)
|
||||||
|
} else {
|
||||||
|
resolvedToOwnAccount = null
|
||||||
|
binding.tvToAccountName.text = displayName
|
||||||
|
binding.tvToBankBic.text = subtitle
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
||||||
|
}
|
||||||
binding.tilTo.visibility = View.GONE
|
binding.tilTo.visibility = View.GONE
|
||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
@@ -630,7 +805,76 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
// ── Transfer ──────────────────────────────────────────────────────────────
|
// ── Transfer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Shared confirm dialog with optional biometric gate, used for both transfers and QR payments. */
|
||||||
|
private fun showConfirmWithBiometric(
|
||||||
|
title: String,
|
||||||
|
message: String? = null,
|
||||||
|
customView: android.view.View? = null,
|
||||||
|
biometricSubtitle: String,
|
||||||
|
onConfirmed: () -> Unit
|
||||||
|
) {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(title)
|
||||||
|
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
if (customView != null) builder.setView(customView) else builder.setMessage(message)
|
||||||
|
val dialog = builder.show()
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
val canAuth = BiometricManager.from(requireContext())
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
if (biometricTransferConfirm && canAuth) {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
dialog.dismiss()
|
||||||
|
onConfirmed()
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||||
|
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||||
|
})
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(getString(R.string.biometric_transfer_title))
|
||||||
|
.setSubtitle(biometricSubtitle)
|
||||||
|
.setNegativeButtonText(getString(android.R.string.cancel))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun initiateTransfer() {
|
private fun initiateTransfer() {
|
||||||
|
// BML QR merchant payment — uses shared confirm dialog, no receipt
|
||||||
|
bmlQrInfo?.let { info ->
|
||||||
|
val src = selectedAccount ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showConfirmWithBiometric(
|
||||||
|
title = getString(R.string.transfer),
|
||||||
|
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}",
|
||||||
|
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||||
|
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val src = selectedAccount ?: run {
|
val src = selectedAccount ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -710,36 +954,31 @@ class TransferFragment : Fragment() {
|
|||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
} else if (!ok) {
|
} else if (!ok) {
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
if (msg == "CONNECTIVITY") {
|
||||||
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
|
||||||
.setTitle(R.string.transfer)
|
|
||||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
|
|
||||||
if (isUsdToMvr || isSrcCredit) {
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val container = LinearLayout(ctx).apply {
|
LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
||||||
}
|
addView(TextView(ctx).apply { text = mainMsg })
|
||||||
container.addView(TextView(ctx).apply { text = mainMsg })
|
if (isUsdToMvr) addView(TextView(ctx).apply {
|
||||||
if (isUsdToMvr) {
|
|
||||||
container.addView(TextView(ctx).apply {
|
|
||||||
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
||||||
setTextColor(Color.RED)
|
setTextColor(Color.RED)
|
||||||
textSize = 16f
|
textSize = 16f
|
||||||
typeface = Typeface.DEFAULT_BOLD
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||||
})
|
})
|
||||||
}
|
if (isSrcCredit) addView(TextView(ctx).apply {
|
||||||
if (isSrcCredit) {
|
|
||||||
container.addView(TextView(ctx).apply {
|
|
||||||
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
||||||
setTextColor(Color.RED)
|
setTextColor(Color.RED)
|
||||||
textSize = 16f
|
textSize = 16f
|
||||||
@@ -747,45 +986,123 @@ class TransferFragment : Fragment() {
|
|||||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
dialogBuilder.setView(container)
|
} else null
|
||||||
} else {
|
showConfirmWithBiometric(
|
||||||
dialogBuilder.setMessage(mainMsg)
|
title = getString(R.string.transfer),
|
||||||
|
message = if (warningView == null) mainMsg else null,
|
||||||
|
customView = warningView,
|
||||||
|
biometricSubtitle = "$currency $amountStr → $destDisplay",
|
||||||
|
onConfirmed = { doTransfer() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeBmlQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
info: BmlQrPayInfo,
|
||||||
|
amount: Double
|
||||||
|
) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val session = bmlSessionFor(src) ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||||
|
|
||||||
val dialog = dialogBuilder.show()
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
val result = withContext(Dispatchers.IO) {
|
||||||
val canAuth = BiometricManager.from(requireContext())
|
try {
|
||||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
if (bmlGatewayQr) {
|
||||||
if (biometricTransferConfirm && canAuth) {
|
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
if (!preOk) return@withContext null
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
}
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
val initiated = BmlQrPayClient().initiatePayment(
|
||||||
dialog.dismiss()
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
doTransfer()
|
if (!initiated) return@withContext null
|
||||||
}
|
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
BmlQrPayClient().confirmPayment(
|
||||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
} catch (e: Exception) {
|
||||||
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
|
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
})
|
if (_binding == null) return@launch
|
||||||
prompt.authenticate(
|
|
||||||
BiometricPrompt.PromptInfo.Builder()
|
if (result == null) {
|
||||||
.setTitle(getString(R.string.biometric_transfer_title))
|
binding.btnTransfer.isEnabled = true
|
||||||
.setSubtitle("$currency $amountStr → $destDisplay")
|
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
.setNegativeButtonText(getString(android.R.string.cancel))
|
return@launch
|
||||||
.build()
|
}
|
||||||
|
if (result.success) {
|
||||||
|
showBmlQrSuccessDialog(
|
||||||
|
merchant = result.merchant.ifBlank { info.merchantName },
|
||||||
|
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||||
|
currency = result.currency.ifBlank { info.currency }
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
val container = android.widget.LinearLayout(ctx).apply {
|
||||||
|
orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
gravity = android.view.Gravity.CENTER_HORIZONTAL
|
||||||
|
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||||
|
}
|
||||||
|
container.addView(android.widget.ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_check_circle)
|
||||||
|
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
|
||||||
|
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||||
|
(64 * dp).toInt(), (64 * dp).toInt()
|
||||||
|
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
||||||
|
})
|
||||||
|
container.addView(android.widget.TextView(ctx).apply {
|
||||||
|
text = "$currency $amount"
|
||||||
|
textSize = 28f
|
||||||
|
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||||
|
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK))
|
||||||
|
gravity = android.view.Gravity.CENTER
|
||||||
|
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||||
|
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||||
|
})
|
||||||
|
container.addView(android.widget.TextView(ctx).apply {
|
||||||
|
text = merchant
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY))
|
||||||
|
gravity = android.view.Gravity.CENTER
|
||||||
|
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||||
|
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL }
|
||||||
|
})
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.bml_qr_payment_success)
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun doMibTransfer(
|
private fun doMibTransfer(
|
||||||
src: BankAccount,
|
src: BankAccount,
|
||||||
destAccount: String,
|
destAccount: String,
|
||||||
@@ -851,7 +1168,7 @@ class TransferFragment : Fragment() {
|
|||||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Triple(false, e.message ?: "Transfer failed", null)
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +1221,7 @@ class TransferFragment : Fragment() {
|
|||||||
// Step 1: initiate
|
// Step 1: initiate
|
||||||
val initiated = try {
|
val initiated = try {
|
||||||
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||||
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
|
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||||
|
|
||||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||||
|
|
||||||
@@ -936,7 +1253,7 @@ class TransferFragment : Fragment() {
|
|||||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Triple(false, e.message ?: "Transfer failed", null)
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,7 +1495,7 @@ class TransferFragment : Fragment() {
|
|||||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Triple(false, e.message ?: "Transfer failed", null as TransferReceiptData?)
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
@@ -1192,7 +1509,11 @@ class TransferFragment : Fragment() {
|
|||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
} else {
|
} else {
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
binding.tilBmlOtp.error = msg
|
if (msg == "CONNECTIVITY") {
|
||||||
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
|
} else {
|
||||||
|
binding.tilBmlOtp.error = msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1553,8 @@ class TransferFragment : Fragment() {
|
|||||||
private fun updateTransferButton() {
|
private fun updateTransferButton() {
|
||||||
if (bmlOtpState != BmlOtpState.NONE) return
|
if (bmlOtpState != BmlOtpState.NONE) return
|
||||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val hasAll = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
val recipientReady = if (bmlQrInfo != null) bmlQrInfo != null else resolvedAccountNumber.isNotBlank()
|
||||||
|
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||||
val bankOffline = "NO_INTERNET" in errors ||
|
val bankOffline = "NO_INTERNET" in errors ||
|
||||||
@@ -1252,6 +1574,7 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedBankName = ""
|
resolvedBankName = ""
|
||||||
|
resolvedToOwnAccount = null
|
||||||
selectedFahipayService = null
|
selectedFahipayService = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.chipGroupService.visibility = View.GONE
|
binding.chipGroupService.visibility = View.GONE
|
||||||
@@ -1278,7 +1601,10 @@ class TransferFragment : Fragment() {
|
|||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (_binding != null) binding.ivToPhoto.setImageBitmap(bitmap)
|
if (_binding != null) {
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
@@ -1406,10 +1732,104 @@ class TransferFragment : Fragment() {
|
|||||||
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
val displayData = AccountListParser.from(acc)
|
||||||
|
val typeLabel = displayData?.typeLabel
|
||||||
|
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||||
|
else acc.accountTypeName.trim()
|
||||||
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
||||||
val balance = AccountListParser.from(acc)?.balance ?: ""
|
if (typeLabel.isNotBlank()) {
|
||||||
|
b.tvDropdownAccountType.text = typeLabel
|
||||||
|
b.tvDropdownAccountType.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
b.tvDropdownAccountType.visibility = View.GONE
|
||||||
|
}
|
||||||
|
val balance = displayData?.balance ?: ""
|
||||||
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
||||||
b.root.alpha = if (inactive) 0.4f else 1f
|
b.root.alpha = if (inactive) 0.4f else 1f
|
||||||
|
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||||
|
when {
|
||||||
|
networkIcon != null -> {
|
||||||
|
b.ivDropdownCardLogo.setImageResource(networkIcon)
|
||||||
|
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
acc.bank == "BML" -> {
|
||||||
|
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||||
|
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||||
|
val imageView = b.ivDropdownCardLogo
|
||||||
|
imageView.tag = localKey
|
||||||
|
if (cachedLocal != null) {
|
||||||
|
imageView.setImageBitmap(cachedLocal)
|
||||||
|
} else {
|
||||||
|
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||||
|
if (acc.profileId.isNotBlank()) {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
dropdownProfileImageCache[localKey] = bitmap
|
||||||
|
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
acc.bank == "FAHIPAY" -> {
|
||||||
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||||
|
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||||
|
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||||
|
val imageView = b.ivDropdownCardLogo
|
||||||
|
imageView.tag = localKey
|
||||||
|
if (cachedLocal != null) {
|
||||||
|
imageView.setImageBitmap(cachedLocal)
|
||||||
|
} else {
|
||||||
|
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||||
|
if (loginId.isNotBlank()) {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
dropdownProfileImageCache[localKey] = bitmap
|
||||||
|
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
acc.bank == "MIB" -> {
|
||||||
|
val hash = acc.profileImageHash
|
||||||
|
val cached = hash?.let { dropdownProfileImageCache[it] }
|
||||||
|
val imageView = b.ivDropdownCardLogo
|
||||||
|
imageView.tag = hash
|
||||||
|
if (cached != null) {
|
||||||
|
imageView.setImageBitmap(cached)
|
||||||
|
} else {
|
||||||
|
imageView.setImageResource(R.drawable.mib_logo)
|
||||||
|
if (hash != null) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val sess = app.anyMibSession() ?: return@withContext null
|
||||||
|
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
|
||||||
|
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
||||||
|
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
dropdownProfileImageCache[hash] = bitmap
|
||||||
|
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
}
|
||||||
b.root
|
b.root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||||
@@ -161,8 +162,15 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
val accounts = accountStates.map { it.account }
|
val accounts = accountStates.map { it.account }
|
||||||
accountStates.clear()
|
accountStates.clear()
|
||||||
accounts.forEach { accountStates.add(AccountState(it)) }
|
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||||
adapter.setTransactions(emptyList())
|
// Restore cache immediately so data stays visible while refreshing
|
||||||
binding.emptyView.visibility = View.GONE
|
val cached = TransactionCache.load(requireContext(), "transfer")
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
allTransactions.addAll(cached)
|
||||||
|
filterAndDisplay()
|
||||||
|
} else {
|
||||||
|
adapter.setTransactions(emptyList())
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
}
|
||||||
loadNextPages()
|
loadNextPages()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +186,14 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
val bannerMsg = java.util.concurrent.atomic.AtomicReference<String?>(null)
|
||||||
|
fun trackError(e: Exception) {
|
||||||
|
when {
|
||||||
|
e is java.io.IOException -> bannerMsg.compareAndSet(null, "IO")
|
||||||
|
e is BankServerException -> bannerMsg.compareAndSet(null, "SERVER:${e.bankName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val newTransactions = withContext(Dispatchers.IO) {
|
val newTransactions = withContext(Dispatchers.IO) {
|
||||||
val results = mutableListOf<BankTransaction>()
|
val results = mutableListOf<BankTransaction>()
|
||||||
|
|
||||||
@@ -215,7 +231,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList<BankTransaction>() }
|
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
|
||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
@@ -233,7 +249,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
if (total > 0) state.fahipayTotal = total
|
if (total > 0) state.fahipayTotal = total
|
||||||
state.fahipayNextStart += list.size
|
state.fahipayNextStart += list.size
|
||||||
results.addAll(list)
|
results.addAll(list)
|
||||||
} catch (_: Exception) {}
|
} catch (e: Exception) { trackError(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||||
@@ -242,23 +258,25 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
val session = app.mibSessions[loginId] ?: continue
|
val session = app.mibSessions[loginId] ?: continue
|
||||||
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||||
app.mibMutex.withLock {
|
app.mibMutex.withLock {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
try {
|
||||||
val profile = profiles.firstOrNull { it.profileId == profileId }
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||||
for (state in states) {
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||||
try {
|
for (state in states) {
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
try {
|
||||||
session = session,
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
accountNo = state.account.accountNumber,
|
session = session,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountNo = state.account.accountNumber,
|
||||||
start = state.mibNextStart,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
pageSize = pageSize
|
start = state.mibNextStart,
|
||||||
)
|
pageSize = pageSize
|
||||||
if (total > 0) state.mibTotalCount = total
|
)
|
||||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
if (total > 0) state.mibTotalCount = total
|
||||||
results.addAll(list)
|
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
} catch (_: Exception) {}
|
results.addAll(list)
|
||||||
}
|
} catch (e: Exception) { trackError(e) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { trackError(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +285,16 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
|
||||||
|
val raw = bannerMsg.get()
|
||||||
|
when {
|
||||||
|
raw == null -> (activity as? HomeActivity)?.hideConnectivityBanner()
|
||||||
|
raw == "IO" -> (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
|
raw.startsWith("SERVER:") -> (activity as? HomeActivity)?.showConnectivityBanner(
|
||||||
|
getString(R.string.connectivity_server_error, raw.removePrefix("SERVER:"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!firstBatchDone) {
|
if (!firstBatchDone) {
|
||||||
firstBatchDone = true
|
firstBatchDone = true
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores and loads profile images locally for BML and Fahipay accounts.
|
||||||
|
*
|
||||||
|
* Key conventions:
|
||||||
|
* BML profile: "bml_${profileId}"
|
||||||
|
* Fahipay login: "fahipay_${loginId}"
|
||||||
|
*/
|
||||||
|
object ProfileImageStore {
|
||||||
|
|
||||||
|
private fun dir(context: Context): File =
|
||||||
|
File(context.filesDir, "profile_images").also { it.mkdirs() }
|
||||||
|
|
||||||
|
private fun file(context: Context, key: String): File =
|
||||||
|
File(dir(context), "${key.replace(Regex("[^A-Za-z0-9_\\-]"), "_")}.jpg")
|
||||||
|
|
||||||
|
fun save(context: Context, key: String, bitmap: Bitmap) {
|
||||||
|
try {
|
||||||
|
file(context, key).outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(context: Context, key: String): Bitmap? = try {
|
||||||
|
val f = file(context, key)
|
||||||
|
if (f.exists()) BitmapFactory.decodeFile(f.absolutePath) else null
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
fun delete(context: Context, key: String) {
|
||||||
|
try { file(context, key).delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exists(context: Context, key: String): Boolean =
|
||||||
|
try { file(context, key).exists() } catch (_: Exception) { false }
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
try { dir(context).listFiles()?.forEach { it.delete() } } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key for a BML profile given its profileId. */
|
||||||
|
fun bmlKey(profileId: String) = "bml_$profileId"
|
||||||
|
|
||||||
|
/** Key for a Fahipay login given its loginId. */
|
||||||
|
fun fahipayKey(loginId: String) = "fahipay_$loginId"
|
||||||
|
|
||||||
|
/** Derives the loginId from a loginTag (e.g. "fahipay_abc" → "abc"). */
|
||||||
|
fun loginIdFromTag(loginTag: String): String =
|
||||||
|
loginTag.substringAfter('_', loginTag)
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
package sh.sar.basedbank.util.bmlapi
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
object BmlCardParser {
|
object BmlCardParser {
|
||||||
|
|
||||||
|
/** Returns the drawable res for the card network logo, or null for non-card/unknown. */
|
||||||
|
fun cardNetworkIcon(account: BankAccount): Int? = when {
|
||||||
|
account.productCode.startsWith("C1") -> R.drawable.visa
|
||||||
|
account.productCode.startsWith("C3") -> R.drawable.americanexpress
|
||||||
|
account.productCode == "C8905" || account.productCode == "C8995" -> R.drawable.visa
|
||||||
|
account.productCode.startsWith("C8") -> R.drawable.mastercard
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the asset path for the card image.
|
* Returns the asset path for the card image.
|
||||||
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
||||||
@@ -12,35 +22,34 @@ object BmlCardParser {
|
|||||||
productCodeToAsset(account.productCode)
|
productCodeToAsset(account.productCode)
|
||||||
|
|
||||||
fun productCodeToAsset(productCode: String): String = when (productCode) {
|
fun productCodeToAsset(productCode: String): String = when (productCode) {
|
||||||
"C8201" -> "cards/bml/master_prepaid.png"
|
"C8201", "C8001", "C8009" -> "cards/bml/master_prepaid.png"
|
||||||
"C8205" -> "cards/bml/master_prepaid_travel.png"
|
"C8205", "C8005", "C8008" -> "cards/bml/master_prepaid_travel.png"
|
||||||
"C8005" -> "cards/bml/master_prepaid_travel.png"
|
"C3007", "C3017", "C3097", "C3095", "C3077", "C3177" -> "cards/bml/amex_debit_green.png"
|
||||||
"C3007" -> "cards/bml/amex_debit_green.png"
|
"C3003", "C3013", "C3053", "C3023", "C3033", "C3052" -> "cards/bml/amex_debit_gold.png"
|
||||||
"C1007" -> "cards/bml/visa_debit.png"
|
"C3009", "C3019", "C3029", "C3099", "C3088", "C3188" -> "cards/bml/amex_credit_gold.png"
|
||||||
"C1003" -> "cards/bml/visa_gold.png"
|
"C3001", "C3011", "C3050", "C3051", "C3031" -> "cards/bml/amex_credit_green.png"
|
||||||
"C8022" -> "cards/bml/master_gold.png"
|
"C3005", "C3015", "C3055", "C3054" -> "cards/bml/amex_platinum.png"
|
||||||
"C1020" -> "cards/bml/visa_debit_platinum.png"
|
"C1003", "C1013", "C1083", "C1084", "C1103", "C1113", "C1183", "C1184" -> "cards/bml/visa_gold.png"
|
||||||
"C8902" -> "cards/bml/master_islamic.png"
|
"C1007", "C1027", "C1097", "C1107", "C1197", "C1077", "C1177" -> "cards/bml/visa_debit.png"
|
||||||
|
"C1020", "C1021" -> "cards/bml/visa_debit_platinum.png"
|
||||||
|
"C8020", "C8022" -> "cards/bml/master_gold.png"
|
||||||
|
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||||
// "?????" -> "cards/bml/amex_credit_gold.png"
|
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||||
// "?????" -> "cards/bml/amex_credit_green.png"
|
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
||||||
// "?????" -> "cards/bml/amex_debit_gold.png"
|
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||||
// "?????" -> "cards/bml/amex_platinum.png"
|
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||||
// "?????" -> "cards/bml/master.png"
|
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||||
// "?????" -> "cards/bml/master_business_debit.png"
|
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||||
// "?????" -> "cards/bml/master_odiveriyaa.png"
|
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||||
// "?????" -> "cards/bml/master_passport.png"
|
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
|
||||||
// "?????" -> "cards/bml/master_platinum.png"
|
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||||
// "?????" -> "cards/bml/master_prepaid_business.png"
|
"C1017" -> "cards/bml/visa_infinite.png"
|
||||||
// "?????" -> "cards/bml/master_world.png"
|
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
|
||||||
// "?????" -> "cards/bml/visa_corporate.png"
|
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
||||||
// "?????" -> "cards/bml/visa_credit.png"
|
"C8925", "C8926" -> "cards/bml/visa_student_blue.png"
|
||||||
// "?????" -> "cards/bml/visa_debit_generic.png"
|
"C1071", "C1073", "C1061", "C1063", "C1161", "C1163" -> "cards/bml/master.png"
|
||||||
// "?????" -> "cards/bml/visa_debit_islamic.png"
|
"C1070", "C1072", "C1059", "C1062", "C1159", "C1162" -> "cards/bml/master_prepaid_business.png"
|
||||||
// "?????" -> "cards/bml/visa_infinite.png"
|
else -> "cards/bml/defaultcard.png"
|
||||||
// "?????" -> "cards/bml/visa_platinum.png"
|
|
||||||
// "?????" -> "cards/bml/visa_student_black.png"
|
|
||||||
// "?????" -> "cards/bml/visa_student_blue.png"
|
|
||||||
else -> "cards/bml/defaultcard.png"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:duration="250"
|
||||||
|
android:propertyName="trimPathEnd"
|
||||||
|
android:valueFrom="0"
|
||||||
|
android:valueTo="1"
|
||||||
|
android:valueType="floatType"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_decelerate" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:duration="250"
|
||||||
|
android:propertyName="trimPathEnd"
|
||||||
|
android:valueFrom="1"
|
||||||
|
android:valueTo="0"
|
||||||
|
android:valueType="floatType"
|
||||||
|
android:interpolator="@android:interpolator/accelerate_decelerate" />
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<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,15.2c1.767,0 3.2,-1.433 3.2,-3.2s-1.433,-3.2 -3.2,-3.2 -3.2,1.433 -3.2,3.2 1.433,3.2 3.2,3.2zM9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
|
||||||
|
</vector>
|
||||||
@@ -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="@android:color/black"
|
||||||
|
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- base for "turning off" animation: line starts invisible -->
|
||||||
|
<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/black"
|
||||||
|
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
||||||
|
<path
|
||||||
|
android:name="line"
|
||||||
|
android:strokeColor="@android:color/black"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M2,4 L22,20"
|
||||||
|
android:trimPathEnd="0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- base for "turning on" animation: line starts fully visible -->
|
||||||
|
<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/black"
|
||||||
|
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
||||||
|
<path
|
||||||
|
android:name="line"
|
||||||
|
android:strokeColor="@android:color/black"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M2,4 L22,20"
|
||||||
|
android:trimPathEnd="1" />
|
||||||
|
</vector>
|
||||||
@@ -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">
|
||||||
|
<!-- bolt with line through it (flash_off) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/black"
|
||||||
|
android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_flashlight_anim_off">
|
||||||
|
<target
|
||||||
|
android:name="line"
|
||||||
|
android:animation="@animator/flashlight_line_appear" />
|
||||||
|
</animated-vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_flashlight_anim_on">
|
||||||
|
<target
|
||||||
|
android:name="line"
|
||||||
|
android:animation="@animator/flashlight_line_disappear" />
|
||||||
|
</animated-vector>
|
||||||
@@ -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="@android:color/black"
|
||||||
|
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01 3.5,-4.51 4.5,6H5l3.5,-4.5z" />
|
||||||
|
</vector>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@android:color/black">
|
android:background="@android:color/black">
|
||||||
@@ -11,26 +12,49 @@
|
|||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/btnContainer"
|
android:id="@+id/btnContainer"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|center_horizontal"
|
android:layout_gravity="bottom|center_horizontal"
|
||||||
android:layout_marginBottom="48dp"
|
android:layout_marginBottom="48dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="16dp">
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.slider.Slider
|
||||||
android:id="@+id/btnPickImage"
|
android:id="@+id/zoomSlider"
|
||||||
style="@style/Widget.Material3.Button.TonalButton"
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:valueFrom="0"
|
||||||
|
android:valueTo="1"
|
||||||
|
android:value="0"
|
||||||
|
app:labelBehavior="gone"
|
||||||
|
app:thumbColor="@android:color/white"
|
||||||
|
app:trackColorActive="@android:color/white"
|
||||||
|
app:trackColorInactive="#80FFFFFF" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_gravity="center_horizontal"
|
||||||
android:text="@string/qr_pick_image" />
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnCancel"
|
android:id="@+id/btnPickImage"
|
||||||
style="@style/Widget.Material3.Button.TonalButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/back" />
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="@string/qr_pick_image"
|
||||||
|
app:icon="@drawable/ic_image" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnFlashlight"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:icon="@drawable/ic_flashlight_anim_on" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,29 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?attr/colorSurface">
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<FrameLayout
|
||||||
android:id="@+id/recyclerView"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:paddingHorizontal="16dp"
|
|
||||||
android:paddingTop="8dp"
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:paddingBottom="16dp"
|
android:id="@+id/recyclerView"
|
||||||
android:clipToPadding="false" />
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
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="@string/accounts_empty"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
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:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<!-- Merchant info card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardMerchant"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="24dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:strokeColor="?attr/colorPrimary">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingVertical="14dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivMerchantIcon"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMerchantName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMerchantAddress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Loading placeholder shown while looking up merchant -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLookingUp"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="24dp"
|
||||||
|
android:text="@string/bml_qr_looking_up"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:gravity="center"
|
||||||
|
android:visibility="visible" />
|
||||||
|
|
||||||
|
<!-- From label + account dropdown -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:text="@string/transfer_label_from"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilFrom"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/transfer_from"
|
||||||
|
android:layout_marginBottom="8dp">
|
||||||
|
|
||||||
|
<AutoCompleteTextView
|
||||||
|
android:id="@+id/actvFrom"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Selected source account info card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardFromInfo"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:strokeColor="?attr/colorPrimary">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="4dp"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivFromPhoto"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromAccountName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromAccountNumber"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromBalance"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnClearFromInfo"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/transfer_clear_recipient" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Amount -->
|
||||||
|
<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:hint="@string/transfer_amount"
|
||||||
|
app:prefixText="MVR "
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnPay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/transfer"
|
||||||
|
android:enabled="false" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
@@ -178,11 +178,14 @@
|
|||||||
|
|
||||||
<!-- Pending Finances card -->
|
<!-- Pending Finances card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardPendingFinances"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
app:cardElevation="1dp"
|
app:cardElevation="1dp"
|
||||||
app:cardCornerRadius="12dp">
|
app:cardCornerRadius="12dp"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
|
||||||
<!-- Amount (optional) -->
|
<!-- Amount (optional) -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/tilAmount"
|
android:id="@+id/tilAmount"
|
||||||
|
|||||||
@@ -101,6 +101,15 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromBalance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btnClearFromInfo"
|
android:id="@+id/btnClearFromInfo"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
@@ -218,10 +227,29 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="2dp"
|
android:layout_marginTop="2dp"
|
||||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToAccountDetails"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToBalance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btnClearToInfo"
|
android:id="@+id/btnClearToInfo"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@
|
|||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:foreground="?attr/selectableItemBackground">
|
android:foreground="?attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<!-- Bank / profile logo -->
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivBankLogo"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto" />
|
||||||
|
|
||||||
<!-- Left: name / number -->
|
<!-- Left: name / number -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -3,38 +3,64 @@
|
|||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
android:paddingHorizontal="16dp"
|
android:paddingHorizontal="16dp"
|
||||||
android:paddingVertical="12dp">
|
android:paddingVertical="12dp">
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/tvDropdownAccountName"
|
android:id="@+id/ivDropdownCardLogo"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="36dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="36dp"
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
android:scaleType="fitCenter"
|
||||||
android:textColor="?attr/colorOnSurface" />
|
android:layout_marginEnd="10dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="vertical">
|
||||||
android:layout_marginTop="2dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvDropdownAccountNumber"
|
android:id="@+id/tvDropdownAccountName"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvDropdownBalance"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
|
||||||
android:textColor="?attr/colorOnSurface" />
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="2dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDropdownAccountNumber"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDropdownBalance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDropdownAccountType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -47,4 +47,13 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBalance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
|
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
|
||||||
<string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string>
|
<string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string>
|
||||||
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
|
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
|
||||||
|
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
|
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
|
||||||
|
|||||||
@@ -189,6 +189,13 @@
|
|||||||
<string name="login_detail_customer_id">Customer ID</string>
|
<string name="login_detail_customer_id">Customer ID</string>
|
||||||
<string name="login_detail_id_card">ID Card</string>
|
<string name="login_detail_id_card">ID Card</string>
|
||||||
<string name="login_detail_profiles">Profiles</string>
|
<string name="login_detail_profiles">Profiles</string>
|
||||||
|
<string name="profile_image_title">Profile photo</string>
|
||||||
|
<string name="profile_image_select">Select image</string>
|
||||||
|
<string name="profile_image_camera">Take from camera</string>
|
||||||
|
<string name="profile_image_remove">Remove</string>
|
||||||
|
<string name="profile_image_uploading">Uploading image…</string>
|
||||||
|
<string name="profile_image_upload_failed">Failed to upload image</string>
|
||||||
|
<string name="profile_image_deleting">Removing image…</string>
|
||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="save">Save</string>
|
<string name="save">Save</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
@@ -238,6 +245,15 @@
|
|||||||
<string name="transfer_send_otp_via">Send verification code via</string>
|
<string name="transfer_send_otp_via">Send verification code via</string>
|
||||||
<string name="transfer_otp_code_hint">Verification code</string>
|
<string name="transfer_otp_code_hint">Verification code</string>
|
||||||
|
|
||||||
|
<!-- BML QR Pay -->
|
||||||
|
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||||
|
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||||
|
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||||
|
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||||
|
|
||||||
|
<!-- Accounts -->
|
||||||
|
<string name="accounts_empty">No accounts found</string>
|
||||||
|
|
||||||
<!-- Contacts -->
|
<!-- Contacts -->
|
||||||
<string name="contacts_empty">No contacts found</string>
|
<string name="contacts_empty">No contacts found</string>
|
||||||
<string name="contacts_search_hint">Search contacts</string>
|
<string name="contacts_search_hint">Search contacts</string>
|
||||||
@@ -298,6 +314,7 @@
|
|||||||
<string name="nav_pay_with_card">Pay with Card</string>
|
<string name="nav_pay_with_card">Pay with Card</string>
|
||||||
<string name="card_pay_qr">QR Pay</string>
|
<string name="card_pay_qr">QR Pay</string>
|
||||||
<string name="card_pay_nfc">NFC Pay</string>
|
<string name="card_pay_nfc">NFC Pay</string>
|
||||||
|
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
||||||
<string name="card_action_change_pin">Change PIN</string>
|
<string name="card_action_change_pin">Change PIN</string>
|
||||||
<string name="card_action_freeze">Freeze</string>
|
<string name="card_action_freeze">Freeze</string>
|
||||||
<string name="card_action_block">Block</string>
|
<string name="card_action_block">Block</string>
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
<paths>
|
<paths>
|
||||||
<cache-path name="receipt_cache" path="receipts/" />
|
<cache-path name="receipt_cache" path="receipts/" />
|
||||||
<cache-path name="qr_cache" path="qr/" />
|
<cache-path name="qr_cache" path="qr/" />
|
||||||
|
<cache-path name="profile_photo_tmp" path="." />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -187,15 +187,15 @@ The app presents different identities to different backends, which is intentiona
|
|||||||
| Backend | User-Agent sent |
|
| Backend | User-Agent sent |
|
||||||
|---|---|
|
|---|---|
|
||||||
| MIB API | `android/1.0` |
|
| MIB API | `android/1.0` |
|
||||||
| MIB WebView | `Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 ... Mobile Safari/537.36` |
|
| MIB WebView | `Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 ... Mobile Safari/537.36` |
|
||||||
| BML web steps | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
|
| BML web steps | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
|
||||||
| BML API calls | `bml-mobile-banking/345 (POCO; Android 14; 22101320I)` |
|
| BML API calls | `bml-mobile-banking/345 (POCO; Android {version}; {model})` |
|
||||||
| Fahipay login/OTP | WebView UA with actual `Build.MODEL` |
|
| Fahipay login/OTP | WebView UA with actual `Build.MODEL` |
|
||||||
| Fahipay API calls | `okhttp/4.12.0` |
|
| Fahipay API calls | `okhttp/4.12.0` |
|
||||||
| Ooredoo | No custom UA |
|
| Ooredoo | No custom UA |
|
||||||
| Dhiraagu | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
|
| Dhiraagu | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
|
||||||
|
|
||||||
The BML API User-Agent hardcodes a specific device model (`POCO; Android 14; 22101320I`) to mimic the official BML mobile app. This is required for the API to accept requests.
|
The BML API User-Agent hardcodes a specific device model (`POCO; Android {version}; {model}`) to mimic the official BML mobile app. This is required for the API to accept requests.
|
||||||
|
|
||||||
The Fahipay login includes `Build.MODEL` and `Build.MANUFACTURER` from the actual device, sent as `device[model]` and `device[manufacturer]` form fields. This is a **device fingerprint** sent to Fahipay on every login.
|
The Fahipay login includes `Build.MODEL` and `Build.MANUFACTURER` from the actual device, sent as `device[model]` and `device[manufacturer]` form fields. This is a **device fingerprint** sent to Fahipay on every login.
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ All hardcoded values in this codebase are protocol constants extracted from reve
|
|||||||
| `P = BigInteger("2410312426921...")` | `MibCrypto.kt:17` | MIB DH prime modulus. Same value as in the official app. | Public parameter — no risk. |
|
| `P = BigInteger("2410312426921...")` | `MibCrypto.kt:17` | MIB DH prime modulus. Same value as in the official app. | Public parameter — no risk. |
|
||||||
| `CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"` | `BmlLoginFlow.kt:30` | BML OAuth client ID, extracted from the official BML mobile app APK. | Not a personal secret — it is the same value for all BML mobile clients. |
|
| `CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"` | `BmlLoginFlow.kt:30` | BML OAuth client ID, extracted from the official BML mobile app APK. | Not a personal secret — it is the same value for all BML mobile clients. |
|
||||||
| `REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"` | `BmlLoginFlow.kt:31` | BML OAuth redirect URI, must match what BML's server expects. | Fixed protocol value. |
|
| `REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"` | `BmlLoginFlow.kt:31` | BML OAuth redirect URI, must match what BML's server expects. | Fixed protocol value. |
|
||||||
| `APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"` | `BmlLoginFlow.kt:32` | Impersonates the official BML app and a specific POCO device model. | Intentional compatibility measure; no personal data. |
|
| `APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android {version}; {model})"` | `BmlLoginFlow.kt:32` | Impersonates the official BML app and a specific POCO device model. | Intentional compatibility measure; no personal data. |
|
||||||
| `APP_VERSION = "2.1.43.345"` | `BmlLoginFlow.kt:33` | BML app version string being impersonated. | Fixed protocol value. |
|
| `APP_VERSION = "2.1.43.345"` | `BmlLoginFlow.kt:33` | BML app version string being impersonated. | Fixed protocol value. |
|
||||||
| `website_id = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"` | `DhiraaguClient.kt:45` | Dhiraagu SDK identifier embedded in the lookup URL. Extracted from Dhiraagu's public Easy Pay page. | Public value embedded in their web page; not a secret. |
|
| `website_id = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"` | `DhiraaguClient.kt:45` | Dhiraagu SDK identifier embedded in the lookup URL. Extracted from Dhiraagu's public Easy Pay page. | Public value embedded in their web page; not a secret. |
|
||||||
| `MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"` | `AddContactSheetFragment.kt:457` | BML's internal bank code (SWIFT/FinInstnId) used to identify MIB as the counterpart bank during BML transfers. | Protocol constant, not a secret. |
|
| `MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"` | `AddContactSheetFragment.kt:457` | BML's internal bank code (SWIFT/FinInstnId) used to identify MIB as the counterpart bank during BML transfers. | Protocol constant, not a secret. |
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Login
|
||||||
|
|
||||||
|
Authenticate a user through the BML web session flow. This involves four sequential HTTP steps before OAuth can begin: seeding cookies, posting credentials, verifying TOTP, and selecting a profile.
|
||||||
|
|
||||||
|
All requests in this flow use the **web User-Agent** and a shared cookie jar that persists cookies (`XSRF-TOKEN`, `blaze_session`, `blaze_identity`) across steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Seed Session Cookies
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/web/login
|
||||||
|
```
|
||||||
|
|
||||||
|
This request initialises the session. The server sets the `XSRF-TOKEN` and `blaze_session` cookies which must be carried through the entire login flow.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
`200 OK` — HTML page. The cookies set in the response headers are the only output needed:
|
||||||
|
|
||||||
|
```
|
||||||
|
Set-Cookie: XSRF-TOKEN=<token>; Path=/; SameSite=Lax
|
||||||
|
Set-Cookie: blaze_session=<session>; Path=/; HttpOnly; SameSite=Lax
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract `XSRF-TOKEN` from the cookie store for use in Step 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Submit Credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/web/login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "A123456",
|
||||||
|
"password": "your_password",
|
||||||
|
"code": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `username` | BML online banking username (typically the national ID card number) |
|
||||||
|
| `password` | Online banking password |
|
||||||
|
| `code` | Always empty string `""` at this step |
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-XSRF-TOKEN` | Value of the `XSRF-TOKEN` cookie from Step 1 |
|
||||||
|
| `Content-Type` | `application/json` |
|
||||||
|
| `User-Agent` | `Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--header 'X-XSRF-TOKEN: <xsrf_token>' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>' \
|
||||||
|
--data '{"username":"A123456","password":"your_password","code":""}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success:** `302` redirect to `/web/login/2fa`. The `blaze_session` cookie is updated.
|
||||||
|
|
||||||
|
**Failure:** Any non-`302` response (typically `200` with the login page re-rendered) means the credentials were rejected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Fetch 2FA Page
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa
|
||||||
|
```
|
||||||
|
|
||||||
|
This request refreshes the `XSRF-TOKEN` cookie for the TOTP submission in Step 4. The response body is an HTML page that can be discarded.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract the fresh `XSRF-TOKEN` from the updated cookie store before Step 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Submit TOTP
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "123456",
|
||||||
|
"channel": "authenticator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `123456` | 6-digit TOTP from the user's authenticator app |
|
||||||
|
| `channel` | `authenticator` | Always `authenticator` for TOTP |
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-XSRF-TOKEN` | Fresh value from Step 3 |
|
||||||
|
| `Content-Type` | `application/json` |
|
||||||
|
| `User-Agent` | Web UA |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--header 'X-XSRF-TOKEN: <xsrf_token_2>' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token_2>; blaze_session=<session>' \
|
||||||
|
--data '{"code":"123456","channel":"authenticator"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success:** `302` redirect to `/web/profile`.
|
||||||
|
|
||||||
|
**Failure:** Any non-`302` (typically a `200` with the 2FA page) means the TOTP was invalid. Generate a fresh code and retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TOTP Details
|
||||||
|
|
||||||
|
BML uses standard RFC 6238 TOTP:
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|---|---|
|
||||||
|
| Algorithm | HMAC-SHA1 |
|
||||||
|
| Period | 30 seconds |
|
||||||
|
| Digits | 6 |
|
||||||
|
| Encoding | Base32 secret |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Profile Selection
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/web/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response — Multi-profile account
|
||||||
|
|
||||||
|
`200 OK` with an HTML page containing an Inertia.js `data-page` payload. After [extracting the Inertia JSON](README.md#inertiajs-response-format):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"props": {
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "12345",
|
||||||
|
"name": "Mohamed Ali",
|
||||||
|
"type": "Profile",
|
||||||
|
"profile": {
|
||||||
|
"profile_type": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "67890",
|
||||||
|
"name": "My Company Ltd",
|
||||||
|
"type": "Business",
|
||||||
|
"profile": {
|
||||||
|
"profile_type": "business"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `profile_id` | `string` | ID used in Step 6 to activate this profile |
|
||||||
|
| `name` | `string` | Display name of the profile |
|
||||||
|
| `type` | `string` | `"Profile"` for personal, `"Business"` for business |
|
||||||
|
| `profile.profile_type` | `string` | `"default"` (personal) or `"business"` |
|
||||||
|
|
||||||
|
### Response — Single-profile account
|
||||||
|
|
||||||
|
`302` redirect (to `/web/redirect` or similar). The server has auto-activated the sole profile and set the `blaze_identity` cookie. Skip Step 6 and proceed directly to [OAuth Token Exchange](03-oauth-token.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Activate Profile
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/web/profile/{profile_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `{profile_id}` with the value from the profile list.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/12345' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response — Personal profile (`profile_type: "default"`)
|
||||||
|
|
||||||
|
`302` redirect (to `/web/redirect` or any path other than `/web/profile/2fa/business`), or `409 Conflict` if the profile was already active.
|
||||||
|
|
||||||
|
Both outcomes mean success: the `blaze_identity` cookie is now set. Proceed to [OAuth Token Exchange](03-oauth-token.md).
|
||||||
|
|
||||||
|
### Response — Business profile (`profile_type: "business"`)
|
||||||
|
|
||||||
|
`302` redirect to `/web/profile/2fa/business`. The business profile requires an additional SMS/email OTP verification step.
|
||||||
|
|
||||||
|
Proceed to [Business Profile OTP](02-business-otp.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- **Personal profile activated** → proceed to **[OAuth Token Exchange](03-oauth-token.md)**
|
||||||
|
- **Business profile** → proceed to **[Business Profile OTP](02-business-otp.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← README](README.md) **Next →** [Business Profile OTP](02-business-otp.md)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Business Profile OTP
|
||||||
|
|
||||||
|
When activating a business profile (where `profile_type` is `"business"`), the server requires SMS or email OTP verification before issuing the `blaze_identity` cookie. This is a two-step process: request an OTP to a chosen channel, then submit the received code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Completed [Login Steps 1–5](01-login.md) (web session cookies are active)
|
||||||
|
- Received a `302` redirect to `/web/profile/2fa/business` after `GET /web/profile/{profile_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Fetch Available OTP Channels
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetches the HTML page for the business 2FA screen. This also refreshes the `XSRF-TOKEN` cookie needed for the POST requests below.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>; blaze_identity=<identity>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
`200 OK` — HTML with an Inertia.js `data-page` payload. After [extracting the Inertia JSON](README.md#inertiajs-response-format):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"props": {
|
||||||
|
"channels": [
|
||||||
|
{
|
||||||
|
"channel": "sms",
|
||||||
|
"description": "SMS",
|
||||||
|
"masked": "+960 9XX XXXX"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"channel": "email",
|
||||||
|
"description": "Email",
|
||||||
|
"masked": "m****@example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `channel` | `string` | Channel identifier — use in Step 2 and Step 3 |
|
||||||
|
| `description` | `string` | Human-readable channel name |
|
||||||
|
| `masked` | `string` | Partially masked destination (for display to user) |
|
||||||
|
|
||||||
|
Present the `masked` values to the user so they can choose where to receive their OTP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Request OTP
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business
|
||||||
|
```
|
||||||
|
|
||||||
|
Send an empty `code` to trigger OTP dispatch to the chosen channel.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "",
|
||||||
|
"channel": "sms"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `""` | Empty — signals OTP dispatch, not submission |
|
||||||
|
| `channel` | `"sms"` or `"email"` | Channel from Step 1 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--header 'X-XSRF-TOKEN: <xsrf_token>' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>' \
|
||||||
|
--data '{"code":"","channel":"sms"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success:** `302` redirect — OTP has been sent.
|
||||||
|
|
||||||
|
**Failure:** Any non-`302` — request failed; check session cookies and retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Submit OTP
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business
|
||||||
|
```
|
||||||
|
|
||||||
|
Before submitting, refresh the XSRF token by making a fresh `GET /web/profile/2fa/business` (repeat Step 1). This ensures the token is valid for the confirmation POST.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "123456",
|
||||||
|
"channel": "sms"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `code` | `"123456"` | OTP received via SMS/email |
|
||||||
|
| `channel` | `"sms"` or `"email"` | Same channel used in Step 2 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--header 'X-XSRF-TOKEN: <xsrf_token_fresh>' \
|
||||||
|
--cookie 'XSRF-TOKEN=<xsrf_token_fresh>; blaze_session=<session>' \
|
||||||
|
--data '{"code":"123456","channel":"sms"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response — Success
|
||||||
|
|
||||||
|
`302` redirect to `/web/redirect` or `409 Conflict`. Both mean the OTP was accepted and the `blaze_identity` cookie is now set for the business profile.
|
||||||
|
|
||||||
|
Proceed to [OAuth Token Exchange](03-oauth-token.md).
|
||||||
|
|
||||||
|
### Response — Invalid OTP
|
||||||
|
|
||||||
|
`302` redirect to any path other than `/web/redirect`. The OTP was wrong. Retry Step 3 with a new code (re-requesting is not usually needed unless the OTP expired).
|
||||||
|
|
||||||
|
### Response — Other failure
|
||||||
|
|
||||||
|
Non-`302` HTTP status — session has likely expired. Return to the full [login flow](01-login.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After the `blaze_identity` cookie is set → proceed to **[OAuth Token Exchange](03-oauth-token.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Login](01-login.md) **Next →** [OAuth Token](03-oauth-token.md)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# OAuth Token Exchange and Refresh
|
||||||
|
|
||||||
|
After the web login flow has set the `blaze_identity` cookie (profile activated), the client exchanges a PKCE authorization code for an `access_token` and `refresh_token`. These tokens are used for all subsequent REST API calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Completed [Login](01-login.md) (and [Business OTP](02-business-otp.md) if applicable)
|
||||||
|
- `blaze_identity` cookie set in the session
|
||||||
|
- PKCE `code_verifier` and `code_challenge` generated at the start of the login session
|
||||||
|
- `Device-ID` generated at the start of the login session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PKCE Parameter Generation
|
||||||
|
|
||||||
|
Generate these once at the start of each login session:
|
||||||
|
|
||||||
|
| Parameter | Generation |
|
||||||
|
|---|---|
|
||||||
|
| `code_verifier` | 72 cryptographically random bytes, base64url-encoded (no padding) |
|
||||||
|
| `code_challenge` | SHA-256 hash of `code_verifier` (as ASCII bytes), base64url-encoded (no padding) |
|
||||||
|
| `Device-ID` | 8 cryptographically random bytes, hex-encoded (16 hex chars) |
|
||||||
|
| `state` | 16 random bytes, base64url-encoded |
|
||||||
|
| `nonce` | 12 random bytes, base64url-encoded |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Authorize (Get Auth Code)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|---|---|
|
||||||
|
| `redirect_uri` | `https://app.bankofmaldives.com.mv/oauth/mobile-callback` |
|
||||||
|
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
|
||||||
|
| `response_type` | `code` |
|
||||||
|
| `state` | Random base64url string (16 bytes) |
|
||||||
|
| `nonce` | Random base64url string (12 bytes) |
|
||||||
|
| `code_challenge` | SHA-256 of `code_verifier`, base64url-encoded |
|
||||||
|
| `code_challenge_method` | `S256` |
|
||||||
|
| `Device-ID` | Random 16-char hex string |
|
||||||
|
| `User-Agent` | App user agent string |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize?redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback&client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7&response_type=code&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256&Device-ID=<device_id>&User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29&x-app-version=2.1.44.348' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android {version}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--cookie 'blaze_session=<session>; blaze_identity=<identity>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
`302` redirect to:
|
||||||
|
```
|
||||||
|
https://app.bankofmaldives.com.mv/oauth/mobile-callback?code=<auth_code>&state=<state>
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract the `code` query parameter from the `Location` header. This is the one-time authorization code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Token Exchange
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `grant_type` | `authorization_code` |
|
||||||
|
| `code` | Auth code from Step 1 |
|
||||||
|
| `code_verifier` | PKCE verifier generated at login start |
|
||||||
|
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
|
||||||
|
| `redirect_uri` | `https://app.bankofmaldives.com.mv/oauth/mobile-callback` |
|
||||||
|
| `Device-ID` | Same device ID used in Step 1 |
|
||||||
|
| `User-Agent` | App user agent string |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
|
||||||
|
--header 'User-Agent: Mozilla/5.0 (Android {version}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||||
|
--data 'grant_type=authorization_code' \
|
||||||
|
--data 'code=<auth_code>' \
|
||||||
|
--data 'code_verifier=<code_verifier>' \
|
||||||
|
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
|
||||||
|
--data 'redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback' \
|
||||||
|
--data 'Device-ID=<device_id>' \
|
||||||
|
--data 'User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29' \
|
||||||
|
--data 'x-app-version=2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
|
||||||
|
"refresh_token": "def50200aabbcc...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `access_token` | `string` | JWT Bearer token — use in `Authorization: Bearer <token>` on all REST API calls |
|
||||||
|
| `refresh_token` | `string` | Long-lived token — use to refresh without re-login |
|
||||||
|
| `token_type` | `string` | Always `"Bearer"` |
|
||||||
|
| `expires_in` | `number` | Token lifetime in seconds (typically `3600`) |
|
||||||
|
|
||||||
|
Store `access_token`, `refresh_token`, `expires_in`, and `Device-ID` together as the session. The `access_token` expires at `now + expires_in * 1000` milliseconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token Refresh
|
||||||
|
|
||||||
|
When the access token expires (on `401` or `419` from any REST endpoint), obtain a new one using the refresh token. No web session or cookies are needed.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `grant_type` | `refresh_token` |
|
||||||
|
| `refresh_token` | Stored refresh token |
|
||||||
|
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
|
||||||
|
| `Device-ID` | Same device ID from the original login |
|
||||||
|
| `User-Agent` | App user agent string |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--data 'grant_type=refresh_token' \
|
||||||
|
--data 'refresh_token=def50200aabbcc...' \
|
||||||
|
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
|
||||||
|
--data 'Device-ID=a1b2c3d4e5f60718' \
|
||||||
|
--data 'User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29' \
|
||||||
|
--data 'x-app-version=2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Same structure as the token exchange response. The server may issue a new `refresh_token` (rotating tokens). If a new one is returned, replace the stored value; otherwise keep the original.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJSUzI1NiJ9...<new>",
|
||||||
|
"refresh_token": "def50200...<new or same>",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Failure:** If `access_token` is absent or blank in the response, the refresh token has expired. Re-run the full [login flow](01-login.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Storage
|
||||||
|
|
||||||
|
Persist the following to represent a saved BML session:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `access_token` | Bearer token for REST API calls |
|
||||||
|
| `refresh_token` | Used to renew without re-login |
|
||||||
|
| `expires_at` | Unix timestamp (ms) when `access_token` expires |
|
||||||
|
| `device_id` | Must be sent with every refresh; ties tokens to the device |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Business Profile OTP](02-business-otp.md) **Next →** [Dashboard](04-dashboard.md)
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Dashboard
|
||||||
|
|
||||||
|
Fetch all accounts for the active profile: CASA (savings/current), cards (prepaid/credit/debit), and loans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"dashboard": [
|
||||||
|
{
|
||||||
|
"id": "abc123def456",
|
||||||
|
"account": "7730000000001",
|
||||||
|
"account_type": "CASA",
|
||||||
|
"product": "Current Account",
|
||||||
|
"alias": "My Account",
|
||||||
|
"currency": "MVR",
|
||||||
|
"account_status": "Active",
|
||||||
|
"availableBalance": 1234.56,
|
||||||
|
"ledgerBalance": 1250.00,
|
||||||
|
"lockedAmount": 15.44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xyz789",
|
||||||
|
"account": "7730000000002",
|
||||||
|
"account_type": "CASA",
|
||||||
|
"product": "Savings Account",
|
||||||
|
"alias": "USD Savings",
|
||||||
|
"currency": "USD",
|
||||||
|
"account_status": "Active",
|
||||||
|
"availableBalance": 500.00,
|
||||||
|
"ledgerBalance": 500.00,
|
||||||
|
"lockedAmount": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card001",
|
||||||
|
"account": "4111111111111111",
|
||||||
|
"account_type": "Card",
|
||||||
|
"product": "Visa Debit",
|
||||||
|
"alias": "My Debit Card",
|
||||||
|
"currency": "MVR",
|
||||||
|
"account_status": "Active",
|
||||||
|
"prepaid_card": false,
|
||||||
|
"product_code": "VISA",
|
||||||
|
"account_visible": false,
|
||||||
|
"cardBalance": {
|
||||||
|
"AvailableLimit": 0.0,
|
||||||
|
"CurrentBalance": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card002",
|
||||||
|
"account": "4111111111112222",
|
||||||
|
"account_type": "Card",
|
||||||
|
"product": "Visa Prepaid",
|
||||||
|
"alias": "Prepaid",
|
||||||
|
"currency": "MVR",
|
||||||
|
"account_status": "Active",
|
||||||
|
"prepaid_card": true,
|
||||||
|
"product_code": "VISA",
|
||||||
|
"account_visible": true,
|
||||||
|
"cardBalance": {
|
||||||
|
"AvailableLimit": 200.00,
|
||||||
|
"CurrentBalance": 50.00
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "loan001",
|
||||||
|
"account": "9900000000001",
|
||||||
|
"account_type": "Loan",
|
||||||
|
"product": "Personal Finance",
|
||||||
|
"alias": "My Loan",
|
||||||
|
"currency": "MVR",
|
||||||
|
"account_status": "Active",
|
||||||
|
"availableBalance": -30000.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Fields
|
||||||
|
|
||||||
|
### Top-level
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` on success |
|
||||||
|
| `payload.dashboard` | `array` | List of account objects; order may vary |
|
||||||
|
|
||||||
|
### Common Account Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Internal BML account ID — use this for history and loan detail calls |
|
||||||
|
| `account` | `string` | Account or card number (as displayed) |
|
||||||
|
| `account_type` | `string` | Account category — see table below |
|
||||||
|
| `product` | `string` | Product name (e.g. `"Current Account"`, `"Visa Prepaid"`) |
|
||||||
|
| `alias` | `string` | User-assigned label; may be blank |
|
||||||
|
| `currency` | `string` | ISO 4217 currency code (e.g. `"MVR"`, `"USD"`) |
|
||||||
|
| `account_status` | `string` | `"Active"` or other status strings |
|
||||||
|
|
||||||
|
### Account Types
|
||||||
|
|
||||||
|
| `account_type` | Description |
|
||||||
|
|---|---|
|
||||||
|
| `CASA` | Current Account or Savings Account |
|
||||||
|
| `Card` | Debit, credit, or prepaid card |
|
||||||
|
| `Loan` | Loan or financing account |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CASA Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `availableBalance` | `number` | Available balance (funds available to use) |
|
||||||
|
| `ledgerBalance` | `number` | Book balance (may include pending transactions) |
|
||||||
|
| `lockedAmount` | `number` | Blocked/reserved amount |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `prepaid_card` | `bool` | `true` for prepaid cards |
|
||||||
|
| `product_code` | `string` | Card scheme (e.g. `"VISA"`) |
|
||||||
|
| `account_visible` | `bool` | `true` for credit cards, `false` for debit cards |
|
||||||
|
| `cardBalance.AvailableLimit` | `number` | Available credit/prepaid balance |
|
||||||
|
| `cardBalance.CurrentBalance` | `number` | Current outstanding balance |
|
||||||
|
|
||||||
|
### Card Profile Types
|
||||||
|
|
||||||
|
| Condition | Type |
|
||||||
|
|---|---|
|
||||||
|
| `prepaid_card: true` | Prepaid card |
|
||||||
|
| `prepaid_card: false` AND `account_visible: true` | Credit card |
|
||||||
|
| `prepaid_card: false` AND `account_visible: false` | Debit card |
|
||||||
|
|
||||||
|
### Product Code → Card Name
|
||||||
|
|
||||||
|
The `product_code` field identifies the specific card product. Known mappings:
|
||||||
|
|
||||||
|
| `product_code` | Card name | Asset |
|
||||||
|
|---|---|---|
|
||||||
|
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
||||||
|
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||||
|
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
|
||||||
|
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
||||||
|
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
|
||||||
|
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
||||||
|
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
||||||
|
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
||||||
|
| `C8901`, `C8991`, `C8980`, `C8981` | Mastercard Passport | `master_passport` |
|
||||||
|
| `C8902`, `C8907`, `C8909`, `C8912`, `C8992`, `C8996`, `C8997`, `C8982`, `C8983` | Mastercard Islamic | `master_islamic` |
|
||||||
|
| `C8905`, `C8995` | Visa Credit | `visa_credit` |
|
||||||
|
| `C8925`, `C8926` | Visa Student Blue | `visa_student_blue` |
|
||||||
|
| `C3001`, `C3011`, `C3050`, `C3051`, `C3031` | Amex Credit Green | `amex_credit_green` |
|
||||||
|
| `C3003`, `C3013`, `C3053`, `C3023`, `C3033`, `C3052` | Amex Debit Gold | `amex_debit_gold` |
|
||||||
|
| `C3005`, `C3015`, `C3055`, `C3054` | Amex Platinum | `amex_platinum` |
|
||||||
|
| `C3007`, `C3017`, `C3097`, `C3095`, `C3077`, `C3177` | Amex Debit Green | `amex_debit_green` |
|
||||||
|
| `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` |
|
||||||
|
| `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` |
|
||||||
|
| `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` |
|
||||||
|
| `C1005`, `C1006`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
|
||||||
|
| `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` |
|
||||||
|
| `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` |
|
||||||
|
| `C1017` | Visa Infinite | `visa_infinite` |
|
||||||
|
| `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` |
|
||||||
|
| `C1030`, `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
|
||||||
|
| `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` |
|
||||||
|
| `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` |
|
||||||
|
| `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` |
|
||||||
|
| _(any other)_ | — | default card image |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loan Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `availableBalance` | `number` | Outstanding balance — returned as a negative number; use `abs()` for display |
|
||||||
|
|
||||||
|
For full loan details (interest rate, repayment schedule, overdue info), call [GET /api/mobile/account/{id}](05-userinfo.md#loan-detail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
### Expired session
|
||||||
|
|
||||||
|
HTTP `401` or `419` — the access token has expired. Attempt [token refresh](03-oauth-token.md#token-refresh).
|
||||||
|
|
||||||
|
### Server error
|
||||||
|
|
||||||
|
HTTP `5xx` — BML server-side error. Retry after a delay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← OAuth Token](03-oauth-token.md) **Next →** [User Info](05-userinfo.md)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# User Info and Session Health
|
||||||
|
|
||||||
|
Two endpoints for checking the current session and fetching the authenticated user's personal details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profile Health Check
|
||||||
|
|
||||||
|
A lightweight call to verify that the access token is still valid. Returns HTTP `200` on success, `401`/`419` if expired.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/profile' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this to probe the session before making more expensive calls. On `401` or `419`, proceed to [token refresh](03-oauth-token.md#token-refresh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Info
|
||||||
|
|
||||||
|
Fetch personal details for the currently authenticated user.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/userinfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/userinfo' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"user": {
|
||||||
|
"fullname": "MOHAMED ALI",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"mobile_phone": "9600000001",
|
||||||
|
"customer_number": "C0000001",
|
||||||
|
"idcard": "A123456",
|
||||||
|
"birthdate": "1990-01-01"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `fullname` | `string` | Full name (typically uppercase) |
|
||||||
|
| `email` | `string` | Registered email address |
|
||||||
|
| `mobile_phone` | `string` | Registered mobile number |
|
||||||
|
| `customer_number` | `string` | BML internal customer ID (e.g. `C0000001`) |
|
||||||
|
| `idcard` | `string` | National ID card number |
|
||||||
|
| `birthdate` | `string` | Date of birth (`YYYY-MM-DD`) |
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `null` payload if the account has no user info record, or `success: false` on any error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loan Detail
|
||||||
|
|
||||||
|
Fetch extended details for a loan account. The `{id}` is the internal account ID (`id` field) from the [dashboard](04-dashboard.md) response.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/loan001' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"loanAmount": 50000.00,
|
||||||
|
"outstandingAmt": -30000.00,
|
||||||
|
"repayAmount": 1500.00,
|
||||||
|
"intRate": 8.5,
|
||||||
|
"loanStatus": "Active",
|
||||||
|
"startDate": "2023-10-26T00:00:00+05:00",
|
||||||
|
"endDate": "2026-10-26T00:00:00+05:00",
|
||||||
|
"noOfRepayOverdue": 0,
|
||||||
|
"overdueAmount": 0.00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `loanAmount` | `number` | Original loan principal |
|
||||||
|
| `outstandingAmt` | `number` | Outstanding balance — returned as a **negative number**; use `abs()` for display |
|
||||||
|
| `repayAmount` | `number` | Periodic repayment amount |
|
||||||
|
| `intRate` | `number` | Interest rate (percentage) |
|
||||||
|
| `loanStatus` | `string` | Status string (e.g. `"Active"`) |
|
||||||
|
| `startDate` | `string` | Loan start date (ISO 8601) |
|
||||||
|
| `endDate` | `string` | Loan end date (ISO 8601) |
|
||||||
|
| `noOfRepayOverdue` | `number` | Number of overdue repayments |
|
||||||
|
| `overdueAmount` | `number` | Total overdue amount |
|
||||||
|
|
||||||
|
On `401`/`419` the session has expired — attempt [token refresh](03-oauth-token.md#token-refresh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Dashboard](04-dashboard.md) **Next →** [Account History](06-account-history.md)
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Account Transaction History
|
||||||
|
|
||||||
|
Fetch paginated transaction history for a CASA (savings/current) account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{accountId}/history/{page}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Path parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `accountId` | Internal account ID (`id` field from [dashboard](04-dashboard.md)) |
|
||||||
|
| `page` | Page number, 1-based |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||||
|
- Internal account `id` from the [Dashboard](04-dashboard.md) response (not the account number)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/abc123def456/history/1' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
The API uses 1-based page numbering. The response includes `totalPages` — increment the page number until you reach or exceed it.
|
||||||
|
|
||||||
|
| Page | URL |
|
||||||
|
|---|---|
|
||||||
|
| First | `/api/mobile/account/{id}/history/1` |
|
||||||
|
| Second | `/api/mobile/account/{id}/history/2` |
|
||||||
|
| N-th | `/api/mobile/account/{id}/history/N` |
|
||||||
|
|
||||||
|
Stop when the current page number exceeds `totalPages`, or when the `history` array is empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"totalPages": 5,
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": "TXN001",
|
||||||
|
"bookingDate": "2026-05-16",
|
||||||
|
"description": "Transfer Debit",
|
||||||
|
"narrative1": "16-05-2026 15-10-25",
|
||||||
|
"narrative2": "Mohamed Ali",
|
||||||
|
"amount": -500.00,
|
||||||
|
"currency": "MVR",
|
||||||
|
"reference": "FT20260516123456"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TXN002",
|
||||||
|
"bookingDate": "2026-05-15",
|
||||||
|
"description": "Transfer Credit",
|
||||||
|
"narrative1": "15-05-2026 10-30-00",
|
||||||
|
"narrative2": "Ahmed Hassan",
|
||||||
|
"amount": 1000.00,
|
||||||
|
"currency": "MVR",
|
||||||
|
"reference": "FT20260515103000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TXN003",
|
||||||
|
"bookingDate": "2026-05-14",
|
||||||
|
"description": "Purchase",
|
||||||
|
"narrative1": "14-05-2026 041500",
|
||||||
|
"narrative2": "",
|
||||||
|
"amount": -75.00,
|
||||||
|
"currency": "MVR",
|
||||||
|
"reference": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Fields
|
||||||
|
|
||||||
|
### Top-level
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` on success |
|
||||||
|
| `payload.totalPages` | `number` | Total number of pages |
|
||||||
|
| `payload.history` | `array` | List of transactions for this page |
|
||||||
|
|
||||||
|
### Transaction Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Transaction ID |
|
||||||
|
| `bookingDate` | `string` | Booking date (fallback date — prefer parsed `narrative1` where available) |
|
||||||
|
| `description` | `string` | Transaction type — see table below |
|
||||||
|
| `narrative1` | `string` | Encodes the precise timestamp; format depends on `description` |
|
||||||
|
| `narrative2` | `string` | Counterparty name (for transfers); may be blank |
|
||||||
|
| `amount` | `number` | Amount — **negative = debit, positive = credit** |
|
||||||
|
| `currency` | `string` | ISO 4217 currency code |
|
||||||
|
| `reference` | `string` | Transfer reference number; blank for non-transfer entries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transaction Descriptions
|
||||||
|
|
||||||
|
| `description` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `Transfer Debit` | Outgoing transfer |
|
||||||
|
| `Transfer Credit` | Incoming transfer |
|
||||||
|
| `Purchase` | Card purchase or point-of-sale transaction |
|
||||||
|
| Other | Various bank-generated transaction types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Date Parsing from `narrative1`
|
||||||
|
|
||||||
|
The `bookingDate` field is date-only. For precise timestamps, parse `narrative1`:
|
||||||
|
|
||||||
|
### Transfer Debit / Transfer Credit
|
||||||
|
|
||||||
|
Format: `DD-MM-YYYY HH-mm-ss`
|
||||||
|
|
||||||
|
```
|
||||||
|
"16-05-2026 15-10-25" → 2026-05-16 15:10:25
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse with `SimpleDateFormat("dd-MM-yyyy HH-mm-ss")`.
|
||||||
|
|
||||||
|
### Purchase
|
||||||
|
|
||||||
|
Format: `DD-MM-YYYY HHmmSS` (time is first 4 digits of the numeric suffix)
|
||||||
|
|
||||||
|
```
|
||||||
|
"14-05-2026 041500" → date: 14-05-2026, time part: "0415" → 04:15
|
||||||
|
→ 2026-05-14 04:15:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse: split on space → date part `DD-MM-YYYY`, time part take first 4 chars → `HH:mm`. Combine and parse with `SimpleDateFormat("dd-MM-yyyy HH:mm:ss")`.
|
||||||
|
|
||||||
|
### All other descriptions
|
||||||
|
|
||||||
|
Fall back to `bookingDate` as-is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Amount Sign Convention
|
||||||
|
|
||||||
|
| Sign | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| Positive (`+`) | Credit — money received |
|
||||||
|
| Negative (`-`) | Debit — money spent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| HTTP Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `401` / `419` | Access token expired — attempt [token refresh](03-oauth-token.md#token-refresh) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← User Info](05-userinfo.md) **Next →** [Card Statement](07-card-statement.md)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Card Statement
|
||||||
|
|
||||||
|
Fetch transaction history for a card account (prepaid, credit, or debit). Returns three sets of entries: outstanding authorisations, unbilled transactions, and billed statement entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||||
|
- Internal card ID (`id` field from [dashboard](04-dashboard.md)) and a target month
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"card": "card001",
|
||||||
|
"month": "2026-05"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `card` | Internal card ID — the `id` field from the dashboard response (not the card number) |
|
||||||
|
| `month` | Target month in `YYYY-MM` format |
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{"card":"card001","month":"2026-05"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The payload contains up to three distinct sections, each may be absent or `null`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"outstanding": {
|
||||||
|
"CardOutStdAuthDetails": [
|
||||||
|
{
|
||||||
|
"TranApprCode": "123456",
|
||||||
|
"DateTime": "2026-05-16T15:10:25",
|
||||||
|
"TranDesc": "Online Purchase - Amazon",
|
||||||
|
"BillingAmount": 50.00,
|
||||||
|
"BillingCcy": "USD"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unbilled": {
|
||||||
|
"CardUnbillTxnDetails": [
|
||||||
|
{
|
||||||
|
"TranApprCode": "789012",
|
||||||
|
"DateTime": "2026-05-15T10:30:00",
|
||||||
|
"TranDesc": "Supermarket",
|
||||||
|
"BillingAmount": 120.00,
|
||||||
|
"BillingCcy": "MVR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cardstatement": [
|
||||||
|
{
|
||||||
|
"TranRef": "STMT20260501001",
|
||||||
|
"TransDate": "2026-05-01",
|
||||||
|
"TranDate": "2026-05-01",
|
||||||
|
"TranDesc": "Monthly Fee",
|
||||||
|
"Description": "Monthly Fee",
|
||||||
|
"TranAmount": 25.00,
|
||||||
|
"TranCcy": "MVR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Sections
|
||||||
|
|
||||||
|
### `outstanding.CardOutStdAuthDetails` — Pending Authorisations
|
||||||
|
|
||||||
|
Transactions that have been authorised but not yet posted. Amounts are in the billing currency.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TranApprCode` | `string` | Authorisation approval code |
|
||||||
|
| `DateTime` | `string` | Authorisation timestamp |
|
||||||
|
| `TranDesc` | `string` | Merchant or transaction description |
|
||||||
|
| `BillingAmount` | `number` | Amount in billing currency (positive) |
|
||||||
|
| `BillingCcy` | `string` | Billing currency code |
|
||||||
|
|
||||||
|
### `unbilled.CardUnbillTxnDetails` — Unbilled Transactions
|
||||||
|
|
||||||
|
Transactions posted to the card but not yet included in a statement cycle.
|
||||||
|
|
||||||
|
Same field structure as `CardOutStdAuthDetails`.
|
||||||
|
|
||||||
|
### `cardstatement` — Billed Statement Entries
|
||||||
|
|
||||||
|
Previously billed transactions from the statement cycle for the requested month.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TranRef` | `string` | Statement reference |
|
||||||
|
| `TransDate` / `TranDate` | `string` | Transaction date (check both keys; `TransDate` takes priority) |
|
||||||
|
| `TranDesc` / `Description` | `string` | Description (check both keys; `TranDesc` takes priority) |
|
||||||
|
| `TranAmount` | `number` | Amount — **stored as positive, displayed as debit** (negate for sign convention) |
|
||||||
|
| `TranCcy` | `string` | Transaction currency |
|
||||||
|
|
||||||
|
> The `TranAmount` in `cardstatement` is always positive in the API response. Negate it to `−TranAmount` so it follows the standard debit-negative convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Amount Sign Convention
|
||||||
|
|
||||||
|
| Section | Sign in response | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `outstanding` / `unbilled` | Positive | Debit (charge to card) |
|
||||||
|
| `cardstatement` | Positive (negate on display) | Debit (charge to card) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| HTTP Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `401` / `419` | Access token expired — attempt [token refresh](03-oauth-token.md#token-refresh) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Account History](06-account-history.md) **Next →** [Transfer](08-transfer.md)
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Transfer
|
||||||
|
|
||||||
|
Initiate and confirm a fund transfer. The process is two-step: an initial POST triggers an OTP to the user's chosen channel; a second POST with the OTP confirms and completes the transfer.
|
||||||
|
|
||||||
|
The same endpoint (`POST /api/mobile/transfer`) handles both steps — the presence of an `otp` field distinguishes them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||||
|
- Validated destination account from [Account Validation](10-validate.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Channels
|
||||||
|
|
||||||
|
Before initiating a transfer, fetch the OTP channels available to the user.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"transfer": {
|
||||||
|
"otpChannel": [
|
||||||
|
{
|
||||||
|
"channel": "token",
|
||||||
|
"description": "Authenticator App",
|
||||||
|
"masked": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"channel": "sms",
|
||||||
|
"description": "SMS",
|
||||||
|
"masked": "+960 9XX XXXX"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `channel` | `string` | Channel identifier — use in transfer requests |
|
||||||
|
| `description` | `string` | Human-readable channel name |
|
||||||
|
| `masked` | `string` | Partially masked destination (blank for authenticator) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Types
|
||||||
|
|
||||||
|
| `transfertype` | Description | Requires `bank` |
|
||||||
|
|---|---|---|
|
||||||
|
| `IAT` | Internal — BML account to BML account | No |
|
||||||
|
| `QTR` | Quick Transfer — via PayMV alias | No |
|
||||||
|
| `DOT` | Domestic Outside Transfer — BML to another bank (e.g. MIB) | Yes — BIC of the destination bank |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Initiate Transfer (Trigger OTP)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"debitAccount": "7730000000001",
|
||||||
|
"creditAccount": "7730000000002",
|
||||||
|
"debitAmount": 100.00,
|
||||||
|
"transfertype": "IAT",
|
||||||
|
"currency": "MVR",
|
||||||
|
"channel": "token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For `DOT` (outside bank) transfers, add `"bank"`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"debitAccount": "7730000000001",
|
||||||
|
"creditAccount": "90101000000001000",
|
||||||
|
"debitAmount": 250.00,
|
||||||
|
"transfertype": "DOT",
|
||||||
|
"currency": "MVR",
|
||||||
|
"channel": "sms",
|
||||||
|
"bank": "MIBVMVMV"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `debitAccount` | `string` | Source BML account number |
|
||||||
|
| `creditAccount` | `string` | Destination account number |
|
||||||
|
| `debitAmount` | `number` | Amount to transfer |
|
||||||
|
| `transfertype` | `string` | `IAT`, `QTR`, or `DOT` |
|
||||||
|
| `currency` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) |
|
||||||
|
| `channel` | `string` | OTP channel from the channels list (e.g. `"token"`, `"sms"`) |
|
||||||
|
| `bank` | `string` | BIC of the destination bank — required for `DOT` only |
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
| `accept` | `application/json` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{"debitAccount":"7730000000001","creditAccount":"7730000000002","debitAmount":100.00,"transfertype":"IAT","currency":"MVR","channel":"token"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response — OTP Triggered
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"code": 22,
|
||||||
|
"message": "OTP sent to your authenticator app"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`success: true` AND `code: 22` together confirm that the OTP has been dispatched. Proceed to Step 2.
|
||||||
|
|
||||||
|
Any other combination means the request failed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Confirm Transfer (Submit OTP)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the exact same body as Step 1, adding the `otp` field (and optionally `remarks`).
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"debitAccount": "7730000000001",
|
||||||
|
"creditAccount": "7730000000002",
|
||||||
|
"debitAmount": 100.00,
|
||||||
|
"transfertype": "IAT",
|
||||||
|
"currency": "MVR",
|
||||||
|
"channel": "token",
|
||||||
|
"otp": "123456",
|
||||||
|
"remarks": "Rent payment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Additional field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `otp` | `string` | OTP received via the chosen channel |
|
||||||
|
| `remarks` | `string` | Optional transfer reference/memo (omit if blank) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{"debitAccount":"7730000000001","creditAccount":"7730000000002","debitAmount":100.00,"transfertype":"IAT","currency":"MVR","channel":"token","otp":"123456","remarks":"Rent payment"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responses
|
||||||
|
|
||||||
|
### Success
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Transfer successful",
|
||||||
|
"payload": {
|
||||||
|
"reference": "FT202605160001",
|
||||||
|
"timestamp": "2026-05-16 15:10:25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` |
|
||||||
|
| `message` | `string` | Confirmation message |
|
||||||
|
| `payload.reference` | `string` | Transfer reference number |
|
||||||
|
| `payload.timestamp` | `string` | Completion timestamp |
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Invalid OTP. Please try again."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`success: false` — the `message` field contains the reason. Common causes: wrong OTP, insufficient balance, invalid account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Card Statement](07-card-statement.md) **Next →** [Contacts](09-contacts.md)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Contacts (Saved Beneficiaries)
|
||||||
|
|
||||||
|
Manage the user's saved beneficiary list: list all contacts, save a new one, and delete an existing one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"account": "7730000000001",
|
||||||
|
"name": "Mohamed Ali",
|
||||||
|
"alias": "Ali",
|
||||||
|
"status": "S",
|
||||||
|
"currency": "MVR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"account": "90101000000001000",
|
||||||
|
"name": "Ahmed Hassan",
|
||||||
|
"alias": "Hassan",
|
||||||
|
"status": "S",
|
||||||
|
"currency": "MVR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `number` | Internal contact ID — use for delete |
|
||||||
|
| `account` | `string` | Beneficiary account number |
|
||||||
|
| `name` | `string` | Account holder name |
|
||||||
|
| `alias` | `string` | User-assigned nickname; falls back to `name` if blank |
|
||||||
|
| `status` | `string` | Contact status (typically `"S"` for saved) |
|
||||||
|
| `currency` | `string` | Transfer currency for this contact |
|
||||||
|
|
||||||
|
Entries where `account` is blank are skipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Save Contact
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contact_type": "BML",
|
||||||
|
"account": "7730000000001",
|
||||||
|
"alias": "Ali",
|
||||||
|
"currency": "MVR",
|
||||||
|
"name": "Mohamed Ali"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `contact_type` | `string` | Yes | Contact category (e.g. `"BML"`) |
|
||||||
|
| `account` | `string` | Yes | Beneficiary account number |
|
||||||
|
| `alias` | `string` | Yes | Display nickname |
|
||||||
|
| `currency` | `string` | No | Transfer currency |
|
||||||
|
| `name` | `string` | No | Full name of the beneficiary |
|
||||||
|
| `swift` | `string` | No | SWIFT/BIC code (for international contacts) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'Accept: application/json' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{"contact_type":"BML","account":"7730000000001","alias":"Ali","currency":"MVR","name":"Mohamed Ali"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`success: true` confirms the contact was saved. `success: false` on failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete Contact
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts/{contactId}
|
||||||
|
```
|
||||||
|
|
||||||
|
BML does not support `DELETE` directly — the delete is sent as a POST with a `_method: delete` body override.
|
||||||
|
|
||||||
|
| Path parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `contactId` | The `id` from the contacts list |
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "_method": "delete" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts/1' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data '{"_method":"delete"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`success: true` confirms the contact was deleted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Transfer](08-transfer.md) **Next →** [Account Validation](10-validate.md)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Account Validation
|
||||||
|
|
||||||
|
Resolve and validate a destination account before initiating a transfer. Two endpoints cover different account types:
|
||||||
|
|
||||||
|
- **BML / alias accounts** — `GET /api/mobile/validate/account/{input}` — resolves BML account numbers and PayMV aliases
|
||||||
|
- **MIB accounts** — `GET /api/mobile/favara/account-verification/{account}/MIB` — resolves MIB accounts via BML's Favara interbank network
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validate BML Account or Alias
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/{input}
|
||||||
|
```
|
||||||
|
|
||||||
|
`{input}` is either a BML account number or a PayMV alias string (e.g. `"MALI"` or a phone number used as alias).
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate a BML account number
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/7730000000001' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'Accept: application/json'
|
||||||
|
|
||||||
|
# Validate an alias
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/MALI' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'Accept: application/json'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responses
|
||||||
|
|
||||||
|
The `validationType` in the response determines which transfer type to use.
|
||||||
|
|
||||||
|
### BML Account (`validationType: "BML"`) — use `IAT`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"trnType": "IAT",
|
||||||
|
"validationType": "BML",
|
||||||
|
"account": "7730000000001",
|
||||||
|
"name": "Mohamed Ali",
|
||||||
|
"alias": "MALI",
|
||||||
|
"currency": "MVR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `trnType` | `string` | Transfer type to use — `"IAT"` |
|
||||||
|
| `validationType` | `string` | `"BML"` |
|
||||||
|
| `account` | `string` | Resolved BML account number |
|
||||||
|
| `name` | `string` | Account holder name |
|
||||||
|
| `alias` | `string` | Alias (if any) — blank or `"null"` if none |
|
||||||
|
| `currency` | `string` | Account currency |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PayMV Alias (`validationType: "alias"`) — use `QTR`
|
||||||
|
|
||||||
|
Returned when the input resolves to a PayMV alias (interbank quick transfer).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": {
|
||||||
|
"trnType": "QTR",
|
||||||
|
"validationType": "alias",
|
||||||
|
"CdtrAcct": {
|
||||||
|
"Acct": "7730000000001",
|
||||||
|
"FinInstnId": ""
|
||||||
|
},
|
||||||
|
"contact_name": "Mohamed Ali",
|
||||||
|
"currency": "MVR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `trnType` | `string` | Transfer type to use — `"QTR"` |
|
||||||
|
| `validationType` | `string` | `"alias"` |
|
||||||
|
| `CdtrAcct.Acct` | `string` | Resolved account number |
|
||||||
|
| `CdtrAcct.FinInstnId` | `string` | BIC of the destination institution (blank for BML-to-BML) |
|
||||||
|
| `contact_name` | `string` | Account holder name |
|
||||||
|
| `currency` | `string` | Account currency |
|
||||||
|
|
||||||
|
> For `QTR` transfers, pass the **original alias input** as `creditAccount` (not the resolved account number), as the alias is what the server routes on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Account or alias not found, or not eligible for transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify MIB Account (Favara)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/favara/account-verification/{account}/MIB
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifies a Maldives Islamic Bank (MIB) account number via BML's Favara interbank network. Use the result for a `DOT` transfer.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/favara/account-verification/90101000000001000/MIB' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'Accept: application/json'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"account": "90101000000001000",
|
||||||
|
"name": "Mohamed Ali",
|
||||||
|
"agnt": "MIBVMVMV"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` if the account exists and is reachable |
|
||||||
|
| `account` | `string` | MIB account number |
|
||||||
|
| `name` | `string` | Account holder name |
|
||||||
|
| `agnt` | `string` | BIC of MIB — send as the `bank` field in the [transfer](08-transfer.md) request |
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Account not found or not accessible via Favara.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Type Summary
|
||||||
|
|
||||||
|
| `validationType` / source | `trnType` to use | `bank` required |
|
||||||
|
|---|---|---|
|
||||||
|
| `"BML"` | `IAT` | No |
|
||||||
|
| `"alias"` | `QTR` | No |
|
||||||
|
| MIB (Favara) | `DOT` | Yes — `agnt` value from verification response |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Contacts](09-contacts.md) **Next →** [Foreign Limits](11-foreign-limits.md)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Foreign Transaction Limits
|
||||||
|
|
||||||
|
Fetch the user's USD foreign currency transaction limits per card, broken down by channel (ATM, ECOM, POS) and category (general, medical).
|
||||||
|
|
||||||
|
This endpoint uses a **different base URL** from the main REST API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
| `Accept` | `application/json` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request GET \
|
||||||
|
--url 'https://app.bankofmaldives.com.mv/api/v2/foreign-limits' \
|
||||||
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
|
--header 'x-app-version: 2.1.44.348' \
|
||||||
|
--header 'Accept: application/json'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"payload": [
|
||||||
|
{
|
||||||
|
"type": "Debit",
|
||||||
|
"used": 150.00,
|
||||||
|
"totalLimit": 1000.00,
|
||||||
|
"generalCap": 1000.00,
|
||||||
|
"generalRemaining": 850.00,
|
||||||
|
"medicalRemaining": 500.00,
|
||||||
|
"isAtmEnabled": true,
|
||||||
|
"isPosEnabled": true,
|
||||||
|
"usageByCategory": {
|
||||||
|
"ATM": {
|
||||||
|
"remaining": 350.00,
|
||||||
|
"limit": 500.00
|
||||||
|
},
|
||||||
|
"ECOM": {
|
||||||
|
"remaining": 200.00,
|
||||||
|
"limit": 300.00
|
||||||
|
},
|
||||||
|
"POS": {
|
||||||
|
"remaining": 300.00,
|
||||||
|
"limit": 500.00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Credit",
|
||||||
|
"used": 0.00,
|
||||||
|
"totalLimit": 2000.00,
|
||||||
|
"generalCap": 2000.00,
|
||||||
|
"generalRemaining": 2000.00,
|
||||||
|
"medicalRemaining": 1000.00,
|
||||||
|
"isAtmEnabled": true,
|
||||||
|
"isPosEnabled": true,
|
||||||
|
"usageByCategory": {
|
||||||
|
"ATM": { "remaining": 500.00, "limit": 500.00 },
|
||||||
|
"ECOM": { "remaining": 500.00, "limit": 500.00 },
|
||||||
|
"POS": { "remaining": 1000.00, "limit": 1000.00 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Fields
|
||||||
|
|
||||||
|
### Top-level
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` on success |
|
||||||
|
| `payload` | `array` | One entry per card type (e.g. Debit, Credit, Prepaid) |
|
||||||
|
|
||||||
|
### Limit Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` | `string` | Card type — e.g. `"Debit"`, `"Credit"`, `"Prepaid"` |
|
||||||
|
| `used` | `number` | Total amount used this period (USD) |
|
||||||
|
| `totalLimit` | `number` | Overall foreign transaction limit (USD) |
|
||||||
|
| `generalCap` | `number` | General spending cap (USD) |
|
||||||
|
| `generalRemaining` | `number` | Remaining general limit (USD) |
|
||||||
|
| `medicalRemaining` | `number` | Remaining medical category limit (USD) |
|
||||||
|
| `isAtmEnabled` | `bool` | Whether ATM withdrawals are enabled |
|
||||||
|
| `isPosEnabled` | `bool` | Whether POS payments are enabled |
|
||||||
|
|
||||||
|
### `usageByCategory` — Channel Breakdown
|
||||||
|
|
||||||
|
| Channel | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ATM` | ATM cash withdrawals |
|
||||||
|
| `ECOM` | E-commerce / online purchases |
|
||||||
|
| `POS` | Point-of-sale payments |
|
||||||
|
|
||||||
|
Each channel object:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `remaining` | `number` | Remaining limit for this channel (USD) |
|
||||||
|
| `limit` | `number` | Total limit for this channel (USD) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| HTTP Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `401` / `419` | Access token expired — attempt [token refresh](03-oauth-token.md#token-refresh) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Account Validation](10-validate.md)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# BML Internet Banking API Documentation
|
||||||
|
|
||||||
|
Reverse-engineered from traffic captures of the BML Mobile Banking Android app (`mv.com.bml.mib`).
|
||||||
|
|
||||||
|
[Play Store](https://play.google.com/store/apps/details?id=mv.com.bml.mib)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bank of Maldives (BML) uses a hybrid authentication model: a web session flow (cookie-based, Inertia.js frontend) handles login and profile selection, which then feeds into a PKCE OAuth 2.0 exchange to obtain a Bearer token for the REST API.
|
||||||
|
|
||||||
|
The login process is stateful and must be executed in order:
|
||||||
|
1. Web login (credentials + TOTP)
|
||||||
|
2. Profile activation
|
||||||
|
3. PKCE OAuth token exchange
|
||||||
|
4. Authenticated REST API calls using the Bearer token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URLs
|
||||||
|
|
||||||
|
| Purpose | Base URL |
|
||||||
|
|---|---|
|
||||||
|
| Web login / OAuth | `https://www.bankofmaldives.com.mv/internetbanking` |
|
||||||
|
| REST API (authenticated) | `https://www.bankofmaldives.com.mv/internetbanking/api/mobile` |
|
||||||
|
| Foreign limits API | `https://app.bankofmaldives.com.mv/api/v2` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Model
|
||||||
|
|
||||||
|
| Value | How obtained | How used |
|
||||||
|
|---|---|---|
|
||||||
|
| `XSRF-TOKEN` cookie | Set by server on `GET /web/login` | Sent as `X-XSRF-TOKEN` header on all web POST requests |
|
||||||
|
| `blaze_session` cookie | Set by server during web flow | Managed automatically by cookie jar |
|
||||||
|
| `blaze_identity` cookie | Set by server after profile activation | Managed automatically; identifies the active profile |
|
||||||
|
| `access_token` | Returned by `POST /oauth/token` after PKCE exchange | Sent as `Authorization: Bearer <token>` on all REST API calls |
|
||||||
|
| `refresh_token` | Returned alongside `access_token` | Used to obtain a new `access_token` without re-login |
|
||||||
|
|
||||||
|
The web flow uses a standard browser cookie jar. The REST API only needs the Bearer token — no cookies required after the OAuth exchange.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth 2.0 PKCE Parameters
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|---|---|
|
||||||
|
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
|
||||||
|
| `redirect_uri` | `https://app.bankofmaldives.com.mv/oauth/mobile-callback` |
|
||||||
|
| `response_type` | `code` |
|
||||||
|
| `code_challenge_method` | `S256` |
|
||||||
|
|
||||||
|
The `code_verifier` is a cryptographically random 72-byte value, base64url-encoded (no padding). The `code_challenge` is the SHA-256 hash of the verifier, also base64url-encoded.
|
||||||
|
|
||||||
|
The `Device-ID` is a random 8-byte hex string generated once per login session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User-Agent Strategy
|
||||||
|
|
||||||
|
Two different User-Agent strings are used depending on the phase:
|
||||||
|
|
||||||
|
| Phase | User-Agent to use |
|
||||||
|
|---|---|
|
||||||
|
| Web login steps (GET/POST `/web/*`, `/oauth/authorize`) | Browser UA |
|
||||||
|
| OAuth token endpoint (`POST /oauth/token`) | Browser UA |
|
||||||
|
| All authenticated REST API calls (`/api/mobile/*`, `/api/v2/*`) | App UA |
|
||||||
|
|
||||||
|
**Browser UA** (used during the entire web session and OAuth flow):
|
||||||
|
```
|
||||||
|
Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**App UA** (used for all REST API calls after the token is obtained):
|
||||||
|
```
|
||||||
|
bml-mobile-banking/348 (<Build.MANUFACTURER>; Android <Build.VERSION.RELEASE>; <Build.MODEL>)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example app UA:
|
||||||
|
```
|
||||||
|
bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inertia.js Response Format
|
||||||
|
|
||||||
|
The BML web frontend is built with Inertia.js. All web page responses embed their data as HTML-escaped JSON in the `data-page` attribute of the root `<div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="app" data-page="{"component":"Login","props":{...}}">
|
||||||
|
```
|
||||||
|
|
||||||
|
To extract: find `data-page="..."`, unescape HTML entities (`"` → `"`, `&` → `&`, `'` → `'`, `<` → `<`, `>` → `>`), then parse as JSON. The useful data is inside the `props` key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
| |
|
||||||
|
| GET /web/login | ← seeds XSRF-TOKEN + blaze_session cookies
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| Set-Cookie: XSRF-TOKEN=...; blaze_session=... |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| POST /web/login | ← JSON: {username, password, code:""}
|
||||||
|
| X-XSRF-TOKEN: <xsrf> |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| 302 Redirect → /web/login/2fa |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| GET /web/login/2fa | ← refreshes cookies
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| Set-Cookie: XSRF-TOKEN=<new> |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| POST /web/login/2fa | ← JSON: {code: <TOTP>, channel: "authenticator"}
|
||||||
|
| X-XSRF-TOKEN: <xsrf2> |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| 302 Redirect → /web/profile |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| GET /web/profile | ← profile picker (multi) or auto-redirect (single)
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| 200 (profile list) OR 302 (auto-activated) |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| (multi-profile) GET /web/profile/{profileId} | ← activate selected profile
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| 302 → /web/redirect (personal) |
|
||||||
|
| 302 → /web/profile/2fa/business (business) |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| GET /oauth/authorize?...&code_challenge=... | ← PKCE authorize
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| 302 → mobile-callback?code=<auth_code> |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| POST /oauth/token | ← exchange auth code for tokens
|
||||||
|
| {code, code_verifier, grant_type, client_id} |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| {access_token, refresh_token, expires_in} |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| GET /api/mobile/dashboard | ← authenticated REST API
|
||||||
|
| Authorization: Bearer <access_token> |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| {success: true, payload: {dashboard: [...]}} |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Expiry
|
||||||
|
|
||||||
|
The access token expires after `expires_in` seconds (typically 3600). On a `401` or `419` response from any REST endpoint:
|
||||||
|
|
||||||
|
1. Attempt to refresh using the stored `refresh_token` → [Token Refresh](03-oauth-token.md#token-refresh)
|
||||||
|
2. If refresh fails, re-run the full login flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Types
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|---|---|
|
||||||
|
| `IAT` | Internal Account Transfer — BML account to BML account |
|
||||||
|
| `QTR` | Quick Transfer — transfer via PayMV alias |
|
||||||
|
| `DOT` | Domestic Outside Transfer — BML to another bank (e.g. MIB) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
| # | File | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | [Login](01-login.md) | Web login: credentials, TOTP, profile selection |
|
||||||
|
| 2 | [Business Profile OTP](02-business-otp.md) | SMS/email OTP for business profile activation |
|
||||||
|
| 3 | [OAuth Token](03-oauth-token.md) | PKCE token exchange and token refresh |
|
||||||
|
| 4 | [Dashboard](04-dashboard.md) | Fetch all accounts (CASA, card, loan) |
|
||||||
|
| 5 | [User Info](05-userinfo.md) | User profile details and session health check |
|
||||||
|
| 6 | [Account History](06-account-history.md) | Paginated transaction history for CASA accounts |
|
||||||
|
| 7 | [Card Statement](07-card-statement.md) | Card transaction history (prepaid, credit, debit) |
|
||||||
|
| 8 | [Transfer](08-transfer.md) | Initiate and confirm fund transfers |
|
||||||
|
| 9 | [Contacts](09-contacts.md) | Saved beneficiaries — list, save, delete |
|
||||||
|
| 10 | [Account Validation](10-validate.md) | Validate BML accounts, aliases, and MIB accounts |
|
||||||
|
| 11 | [Foreign Limits](11-foreign-limits.md) | USD foreign transaction limits by card and channel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Next →** [Login](01-login.md)
|
||||||
@@ -29,8 +29,8 @@ POST https://fahipay.mv/api/app/login/
|
|||||||
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
|
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
|
||||||
| `device[platform]` | `Android` | |
|
| `device[platform]` | `Android` | |
|
||||||
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
|
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
|
||||||
| `device[model]` | `22101320I` | `Build.MODEL` |
|
| `device[model]` | `{model}` | `Build.MODEL` |
|
||||||
| `device[manufacturer]` | `Xiaomi` | `Build.MANUFACTURER` |
|
| `device[manufacturer]` | `{manufacturer}` | `Build.MANUFACTURER` |
|
||||||
| `device[isVirtual]` | `false` | |
|
| `device[isVirtual]` | `false` | |
|
||||||
| `device[serial]` | `unknown` | |
|
| `device[serial]` | `unknown` | |
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ curl --request POST \
|
|||||||
--header 'accept: application/json' \
|
--header 'accept: application/json' \
|
||||||
--header 'accept-encoding: gzip, deflate, br' \
|
--header 'accept-encoding: gzip, deflate, br' \
|
||||||
--header 'connection: keep-alive' \
|
--header 'connection: keep-alive' \
|
||||||
--header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
--header 'user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
||||||
--form 'email=A123456' \
|
--form 'email=A123456' \
|
||||||
--form 'password=your_password' \
|
--form 'password=your_password' \
|
||||||
--form 'grant_type=auth_id' \
|
--form 'grant_type=auth_id' \
|
||||||
@@ -57,8 +57,8 @@ curl --request POST \
|
|||||||
--form 'device[available]=true' \
|
--form 'device[available]=true' \
|
||||||
--form 'device[platform]=Android' \
|
--form 'device[platform]=Android' \
|
||||||
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
||||||
--form 'device[model]=22101320I' \
|
--form 'device[model]={model}' \
|
||||||
--form 'device[manufacturer]=Xiaomi' \
|
--form 'device[manufacturer]={manufacturer}' \
|
||||||
--form 'device[isVirtual]=false' \
|
--form 'device[isVirtual]=false' \
|
||||||
--form 'device[serial]=unknown'
|
--form 'device[serial]=unknown'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ POST https://fahipay.mv/api/app/otp/
|
|||||||
| `device[available]` | `true` | Same device fields as login — must match |
|
| `device[available]` | `true` | Same device fields as login — must match |
|
||||||
| `device[platform]` | `Android` | |
|
| `device[platform]` | `Android` | |
|
||||||
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
|
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
|
||||||
| `device[model]` | `22101320I` | |
|
| `device[model]` | `{model}` | |
|
||||||
| `device[manufacturer]` | `Xiaomi` | |
|
| `device[manufacturer]` | `{manufacturer}` | |
|
||||||
| `device[isVirtual]` | `false` | |
|
| `device[isVirtual]` | `false` | |
|
||||||
| `device[serial]` | `unknown` | |
|
| `device[serial]` | `unknown` | |
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ curl --request POST \
|
|||||||
--header 'accept: application/json' \
|
--header 'accept: application/json' \
|
||||||
--header 'accept-encoding: gzip, deflate, br' \
|
--header 'accept-encoding: gzip, deflate, br' \
|
||||||
--header 'connection: keep-alive' \
|
--header 'connection: keep-alive' \
|
||||||
--header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
--header 'user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
||||||
--form 'code=123456' \
|
--form 'code=123456' \
|
||||||
--form 'channel=totp' \
|
--form 'channel=totp' \
|
||||||
--form 'action=login' \
|
--form 'action=login' \
|
||||||
@@ -68,8 +68,8 @@ curl --request POST \
|
|||||||
--form 'device[available]=true' \
|
--form 'device[available]=true' \
|
||||||
--form 'device[platform]=Android' \
|
--form 'device[platform]=Android' \
|
||||||
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
||||||
--form 'device[model]=22101320I' \
|
--form 'device[model]={model}' \
|
||||||
--form 'device[manufacturer]=Xiaomi' \
|
--form 'device[manufacturer]={manufacturer}' \
|
||||||
--form 'device[isVirtual]=false' \
|
--form 'device[isVirtual]=false' \
|
||||||
--form 'device[serial]=unknown'
|
--form 'device[serial]=unknown'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Fahipay API Documentation
|
# Fahipay API Documentation
|
||||||
|
|
||||||
Reverse-engineered from traffic captures of the Fahipay Android WebView app (`fahipay.mv`).
|
Reverse-engineered from traffic captures of the Fahipay Android WebView app (`mv.fahipay`).
|
||||||
|
|
||||||
|
[Play Store](https://play.google.com/store/apps/details?id=mv.fahipay)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ Content-Type: multipart/form-data; boundary=<boundary>
|
|||||||
accept: application/json
|
accept: application/json
|
||||||
accept-encoding: gzip, deflate, br
|
accept-encoding: gzip, deflate, br
|
||||||
connection: keep-alive
|
connection: keep-alive
|
||||||
user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
|
user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authenticated data endpoints
|
### Authenticated data endpoints
|
||||||
@@ -64,8 +66,8 @@ All login and OTP requests include a standard set of device fields:
|
|||||||
| `device[available]` | `true` | Always `true` |
|
| `device[available]` | `true` | Always `true` |
|
||||||
| `device[platform]` | `Android` | Always `Android` |
|
| `device[platform]` | `Android` | Always `Android` |
|
||||||
| `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted |
|
| `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted |
|
||||||
| `device[model]` | `22101320I` | Device model string |
|
| `device[model]` | `{model}` | Device model string |
|
||||||
| `device[manufacturer]` | `Xiaomi` | Device manufacturer |
|
| `device[manufacturer]` | `{manufacturer}` | Device manufacturer |
|
||||||
| `device[isVirtual]` | `false` | Always `false` |
|
| `device[isVirtual]` | `false` | Always `false` |
|
||||||
| `device[serial]` | `unknown` | Always `unknown` |
|
| `device[serial]` | `unknown` | Always `unknown` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Encryption, Key Exchange & Nonce
|
||||||
|
|
||||||
|
All traffic to the encrypted API (`faisanet.mib.com.mv`) uses Blowfish encryption. This document covers the cipher, the DH key exchange that derives the session key, and the nonce algorithm required by every request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cipher
|
||||||
|
|
||||||
|
- **Algorithm**: Blowfish, ECB mode, PKCS5 padding
|
||||||
|
- **Input**: raw UTF-8 bytes of the JSON payload string
|
||||||
|
- **Key**: raw UTF-8 bytes of the key string
|
||||||
|
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from Crypto.Cipher import Blowfish
|
||||||
|
from Crypto.Util.Padding import pad, unpad
|
||||||
|
import base64, json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
def encrypt(payload: dict, key: str) -> str:
|
||||||
|
plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
||||||
|
key_bytes = key.encode('latin-1')
|
||||||
|
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||||
|
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
|
||||||
|
return base64.b64encode(ct).decode()
|
||||||
|
|
||||||
|
def decrypt(ciphertext_b64: str, key: str) -> dict:
|
||||||
|
key_bytes = key.encode('latin-1')
|
||||||
|
ct = base64.b64decode(ciphertext_b64)
|
||||||
|
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||||
|
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
|
||||||
|
return json.loads(plaintext.decode('utf-8'))
|
||||||
|
|
||||||
|
def build_request_body(payload: dict, key: str, extra_fields: dict = {}) -> str:
|
||||||
|
sfunc = payload.get('sfunc', '')
|
||||||
|
encrypted = encrypt(payload, key)
|
||||||
|
body = '&'.join(f"{k}={v}" for k, v in extra_fields.items())
|
||||||
|
if body:
|
||||||
|
return f"{body}&sfunc={sfunc}&data={quote(encrypted)}"
|
||||||
|
return f"sfunc={sfunc}&data={quote(encrypted)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / Response Transport
|
||||||
|
|
||||||
|
**Request** — form field `data`:
|
||||||
|
```
|
||||||
|
sfunc=<value>&data=<url-encoded base64 Blowfish ciphertext>
|
||||||
|
```
|
||||||
|
|
||||||
|
For `sfunc=n` calls, `xxid` must be the **first** field:
|
||||||
|
```
|
||||||
|
xxid=<session_xxid>&sfunc=n&data=<encrypted>
|
||||||
|
```
|
||||||
|
|
||||||
|
For `sfunc=i` calls, `key2` is a separate unencrypted field:
|
||||||
|
```
|
||||||
|
key2=<key2>&sfunc=i&data=<encrypted>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** — body is raw base64 Blowfish ciphertext (no form encoding); base64-decode then decrypt directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
| Key | Value | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEFAULT_KEY` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` | `sfunc=r` key exchange request/response |
|
||||||
|
| Session key | DH-derived (44-char base64, 32 bytes) | All subsequent requests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diffie-Hellman Session Key Derivation
|
||||||
|
|
||||||
|
The session key is derived via a custom DH exchange. All three parameters are hardcoded in the app — this provides no real security since the private key `A` never rotates.
|
||||||
|
|
||||||
|
> **Note**: The variable names in the app source are **swapped** from their DH role. `A_VALUE` in source is the exponent (shorter number); `P_VALUE` is the prime modulus (longer number).
|
||||||
|
|
||||||
|
```
|
||||||
|
G (generator) = 2
|
||||||
|
A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||||
|
P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Client sends `cmod = G^A mod P` (as decimal string) in the `sfunc=r` request
|
||||||
|
2. Server responds with `smod` (its DH public key, as decimal string)
|
||||||
|
3. Client computes shared secret: `shared = smod^A mod P`
|
||||||
|
4. Client SHA-256 hashes `str(shared)` → uppercase hex
|
||||||
|
5. Client converts the hex string to raw bytes, then base64-encodes → session key
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib, base64
|
||||||
|
|
||||||
|
def derive_session_key(smod: int) -> str:
|
||||||
|
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||||
|
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||||
|
shared = pow(smod, A, P)
|
||||||
|
sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
|
||||||
|
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is always a **44-character base64 string** (32 bytes). It changes every session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nonce Computation
|
||||||
|
|
||||||
|
Every request after key exchange includes a `nonce` field. It is computed from the `nonceGenerator` string returned by the key exchange response.
|
||||||
|
|
||||||
|
### `nonceGenerator` format
|
||||||
|
|
||||||
|
A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens. Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
|
||||||
|
|
||||||
|
```
|
||||||
|
M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nonce output format
|
||||||
|
|
||||||
|
4 groups separated by `-`. Each group: a zero-padded 5-digit seed followed by 7 two-digit numbers separated by spaces.
|
||||||
|
|
||||||
|
```
|
||||||
|
08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
|
||||||
|
```
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
**Phase 1 — seed values (one per group):**
|
||||||
|
|
||||||
|
For each of the 4 groups:
|
||||||
|
1. Extract the number from `token[0]` (e.g. `M85` → `85`)
|
||||||
|
2. Generate random `r = floor(random() * 99) + 1` (range 1–99 inclusive)
|
||||||
|
3. `product = N * r` → zero-pad to 5 digits
|
||||||
|
4. `digitSum = sum of digits of padded`
|
||||||
|
5. `lastTwo = int(padded[-2:])` (last two digits)
|
||||||
|
6. Accumulate `cumSum += digitSum`
|
||||||
|
|
||||||
|
**Phase 2 — nonce digits (tokens 1–7 of each group):**
|
||||||
|
|
||||||
|
For each group, start with `carry = lastTwo[i]`:
|
||||||
|
|
||||||
|
| op letter | Formula |
|
||||||
|
|---|---|
|
||||||
|
| `M` | `(carry % num) + digitSum + cumSum` |
|
||||||
|
| `A` | `carry + num + digitSum + cumSum` |
|
||||||
|
| `S` | `(carry * carry) + num + digitSum + cumSum` |
|
||||||
|
| `X` | `(carry * num) + digitSum + cumSum` |
|
||||||
|
| `C` | `(carry * carry * carry) + num + digitSum + cumSum` |
|
||||||
|
|
||||||
|
Nonce digit = last two digits of the result. Update `carry = nonceDigit` for the next token.
|
||||||
|
|
||||||
|
**Output**: join padded seed + 7 two-digit nonce digits per group, join 4 groups with `-`.
|
||||||
|
|
||||||
|
### Python implementation
|
||||||
|
|
||||||
|
```python
|
||||||
|
import math, random
|
||||||
|
|
||||||
|
def generate_nonce(nonce_generator: str) -> str:
|
||||||
|
groups = nonce_generator.split('-')
|
||||||
|
padded_list, last_two, digit_sum = [], [], []
|
||||||
|
cum_sum = 0
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
tokens = group.split(' ')
|
||||||
|
n = int(''.join(c for c in tokens[0] if c.isdigit()))
|
||||||
|
r = math.floor(random.random() * 99) + 1
|
||||||
|
product = n * r
|
||||||
|
padded = str(product).zfill(5)
|
||||||
|
ds = sum(int(d) for d in padded)
|
||||||
|
lt = int(padded[-2:])
|
||||||
|
padded_list.append(padded)
|
||||||
|
last_two.append(lt)
|
||||||
|
digit_sum.append(ds)
|
||||||
|
cum_sum += ds
|
||||||
|
|
||||||
|
result_groups = []
|
||||||
|
for i, group in enumerate(groups):
|
||||||
|
tokens = group.split(' ')
|
||||||
|
carry = last_two[i]
|
||||||
|
ds = digit_sum[i]
|
||||||
|
nonce_digits = []
|
||||||
|
for token in tokens[1:]:
|
||||||
|
op = ''.join(c for c in token if c.isalpha())
|
||||||
|
num = int(''.join(c for c in token if c.isdigit()))
|
||||||
|
if op == 'M': val = (carry % num) + ds + cum_sum
|
||||||
|
elif op == 'A': val = carry + num + ds + cum_sum
|
||||||
|
elif op == 'S': val = (carry * carry) + num + ds + cum_sum
|
||||||
|
elif op == 'X': val = (carry * num) + ds + cum_sum
|
||||||
|
elif op == 'C': val = (carry * carry * carry) + num + ds + cum_sum
|
||||||
|
else: val = 0
|
||||||
|
digit = int(str(val)[-2:])
|
||||||
|
nonce_digits.append(digit)
|
||||||
|
carry = digit
|
||||||
|
group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
|
||||||
|
result_groups.append(group_str)
|
||||||
|
|
||||||
|
return '-'.join(result_groups)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- `nonce` and `sodium` are separate fields. `sodium` is an independent random integer (~23–24 bit range).
|
||||||
|
- The `nonceGenerator` is returned once by the key exchange response and reused for the entire session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known `sfunc` / `routePath` Values
|
||||||
|
|
||||||
|
| `sfunc` | `routePath` | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `r` | `S40` | Initial DH key exchange (DEFAULT_KEY) |
|
||||||
|
| `i` | `S40` | Authenticated DH key exchange (key1/key2) |
|
||||||
|
| `n` | `A44` | Get auth type / `userSalt` |
|
||||||
|
| `n` | `A41` | Regular login initialization |
|
||||||
|
| `n` | `A42` | OTP verification (regular login) |
|
||||||
|
| `n` | `C41` | Device registration initialization |
|
||||||
|
| `n` | `C42` | OTP verification (registration) |
|
||||||
|
| `n` | `P41` | Get profile image (by hash) |
|
||||||
|
| `n` | `P40` | Update profile image |
|
||||||
|
| `n` | `P42` | Delete profile image |
|
||||||
|
| `n` | `P47` | Select profile / fetch account balances |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next →** [Login Flow](02-login.md)
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# Login Flow
|
||||||
|
|
||||||
|
MIB uses a two-phase authentication model:
|
||||||
|
|
||||||
|
| Phase | Trigger | Key used |
|
||||||
|
|---|---|---|
|
||||||
|
| **Device Registration** | First time this device+account pair is seen | `DEFAULT_KEY` → DH session key |
|
||||||
|
| **Regular Login** | Every subsequent login (stored `key1`/`key2`) | `key1` → DH session key |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Password Hashing (`pgf03`)
|
||||||
|
|
||||||
|
The password is never sent in plaintext. Required by both `C41` (registration) and `A41` (login).
|
||||||
|
|
||||||
|
```
|
||||||
|
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
All SHA-256 values are uppercase hex strings. `clientSalt` is a fresh random 32-character alphanumeric string each time.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||||
|
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
|
||||||
|
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
|
||||||
|
return hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device Registration Flow (first time only)
|
||||||
|
|
||||||
|
```
|
||||||
|
[0] sfunc=r DEFAULT_KEY → DH exchange → derive session_key_1, get xxid + nonceGenerator
|
||||||
|
[1] sfunc=n A44 → get userSalt
|
||||||
|
[2] sfunc=n C41 → submit credentials → returns key1, key2 (persist!)
|
||||||
|
[3] sfunc=n C42 → verify OTP
|
||||||
|
[4–8] regular login (below) using the key1/key2 just received
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regular Login Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
[0] sfunc=i key1 → DH exchange → derive session_key_2, get xxid + nonceGenerator
|
||||||
|
[1] sfunc=n A44 → get userSalt
|
||||||
|
[2] sfunc=n A41 → submit credentials → returns otpTypes, email, uuid
|
||||||
|
[3] sfunc=n P41 → fetch profile image (optional)
|
||||||
|
[4] sfunc=n A42 → verify OTP → session established
|
||||||
|
[5] sfunc=n P47 → select profile → returns accountBalance array
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-Step Reference
|
||||||
|
|
||||||
|
### [0] Initial Key Exchange — `sfunc=r`
|
||||||
|
|
||||||
|
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
|
||||||
|
|
||||||
|
**Request** (outer + inner are encrypted together):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "r",
|
||||||
|
"data": {
|
||||||
|
"cmod": "<G^A mod P as decimal string>",
|
||||||
|
"appId": "IOS17.2-<15 random alphanumeric chars>",
|
||||||
|
"routePath": "S40",
|
||||||
|
"sodium": "<random 20-bit int as string>",
|
||||||
|
"xxid": "<random 40-bit int as string>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (decrypted with `DEFAULT_KEY`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"reasonCode": "201",
|
||||||
|
"reasonText": "Key generated successfully.",
|
||||||
|
"smod": "<server DH public key as decimal string>",
|
||||||
|
"nonceGenerator": "<instruction string>",
|
||||||
|
"xxid": "<session token — use for all subsequent calls>",
|
||||||
|
"sodium": "<server random>",
|
||||||
|
"encMethod": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After: `session_key = derive_session_key(int(smod))`. Save `xxid` and `nonceGenerator`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
|
||||||
|
|
||||||
|
**Key**: session key
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"uname": "<username>",
|
||||||
|
"nonce": "<computed nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random>",
|
||||||
|
"routePath": "A44",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [{ "loginType": "1", "userSalt": "<server salt>" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `userSalt` in `pgf03` computation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2a] Device Registration Init — `sfunc=n`, `routePath: C41`
|
||||||
|
|
||||||
|
_First-time only._
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"uname": "<username>",
|
||||||
|
"pgf03": "<computed>",
|
||||||
|
"clientSalt": "<random 32-char string>",
|
||||||
|
"nonce": "<nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random>",
|
||||||
|
"routePath": "C41",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"reasonCode": "201",
|
||||||
|
"primaryOTPType": "3",
|
||||||
|
"otpTypes": [2, 3],
|
||||||
|
"fullName": "<user full name>",
|
||||||
|
"customerImgHash": "<hash>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [3a] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
|
||||||
|
|
||||||
|
_First-time only. Receive and persist `key1`/`key2`._
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"otp": "<6-digit OTP>",
|
||||||
|
"uname": "<username>",
|
||||||
|
"otpType": "3",
|
||||||
|
"nonce": "<nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random>",
|
||||||
|
"routePath": "C42",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"reasonCode": "101",
|
||||||
|
"data": [{
|
||||||
|
"key1": "<store securely — Blowfish key for next sfunc=i>",
|
||||||
|
"key2": "<store securely — sent plaintext in sfunc=i wrapper>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"encryptionMethod": "2"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`key1` and `key2` are long-lived device credentials. Store them securely on the device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [0b] Authenticated Key Exchange — `sfunc=i`
|
||||||
|
|
||||||
|
_Regular login. `key2` is a separate unencrypted outer field._
|
||||||
|
|
||||||
|
**Key**: `key1`
|
||||||
|
|
||||||
|
Form body: `key2=<key2>&sfunc=i&data=<encrypted payload>`
|
||||||
|
|
||||||
|
**Encrypted payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "i",
|
||||||
|
"key2": "<key2>",
|
||||||
|
"data": {
|
||||||
|
"cmod": "<G^A mod P>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"routePath": "S40",
|
||||||
|
"sodium": "<random 20-bit int>",
|
||||||
|
"xxid": "<random 40-bit int>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (decrypted with `key1`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"smod": "<new server DH public key>",
|
||||||
|
"nonceGenerator": "<new instruction string>",
|
||||||
|
"xxid": "<new session token>",
|
||||||
|
"encMethod": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After: derive new session key, replace `xxid` and `nonceGenerator`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2b] Regular Login Init — `sfunc=n`, `routePath: A41`
|
||||||
|
|
||||||
|
**Key**: session key
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"uname": "<username>",
|
||||||
|
"pgf03": "<computed>",
|
||||||
|
"clientSalt": "<random 32-char>",
|
||||||
|
"pmodTime": 0,
|
||||||
|
"requireBankData": 1,
|
||||||
|
"nonce": "<nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random>",
|
||||||
|
"routePath": "A41",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"reasonCode": "104",
|
||||||
|
"primaryOTPType": "3",
|
||||||
|
"otpTypes": [2, 3],
|
||||||
|
"email": "<masked email>",
|
||||||
|
"uuid": "<uuid1>",
|
||||||
|
"uuid2": "<uuid2>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [3b] Get Profile Image — `sfunc=n`, `routePath: P41`
|
||||||
|
|
||||||
|
Optional. Fetch the user's avatar to display on the OTP screen.
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"imageHash": "<customerImgHash from C41/A41>",
|
||||||
|
"nonce": "<nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random>",
|
||||||
|
"routePath": "P41",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"reasonCode": "201",
|
||||||
|
"profileImage": "<base64-encoded JPEG>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [4b] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"otp": "<6-digit OTP>",
|
||||||
|
"uname": "<username>",
|
||||||
|
"otpType": "3",
|
||||||
|
"nonce": "<nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random>",
|
||||||
|
"routePath": "A42",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After successful `A42`, the `xxid` and `nonceGenerator` from the `sfunc=i` response become the WebView session cookies. See [README](README.md) for the cookie format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [5] Select Profile — `sfunc=n`, `routePath: P47`
|
||||||
|
|
||||||
|
See [03-accounts.md](03-accounts.md) for the full P47 reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Encryption](01-encryption.md) **Next →** [Accounts](03-accounts.md)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Accounts & Balances
|
||||||
|
|
||||||
|
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`). The login init call (`A41`) returns an empty `accountBalance` array — balances are only available after `P47`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select Profile — `sfunc=n`, `routePath: P47`
|
||||||
|
|
||||||
|
**Key**: session key (from `sfunc=i` response)
|
||||||
|
|
||||||
|
**Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sfunc": "n",
|
||||||
|
"xxid": "<session xxid>",
|
||||||
|
"data": {
|
||||||
|
"profileType": "<profileType from A41 operatingProfiles>",
|
||||||
|
"profileId": "<profileId from A41 operatingProfiles>",
|
||||||
|
"nonce": "<computed nonce>",
|
||||||
|
"appId": "<appId>",
|
||||||
|
"sodium": "<random 20-bit int>",
|
||||||
|
"routePath": "P47",
|
||||||
|
"xxid": "<session xxid>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"reasonCode": "101",
|
||||||
|
"reasonText": "Profile Selected!",
|
||||||
|
"landingPage": "0",
|
||||||
|
"accountBalance": [ ... ],
|
||||||
|
"accessRights": { ... },
|
||||||
|
"services": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profiles (from `A41` response)
|
||||||
|
|
||||||
|
The `A41` login init response includes `operatingProfiles`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operatingProfiles": [
|
||||||
|
{
|
||||||
|
"profileId": "<profile ID>",
|
||||||
|
"customerProfileId": "<customer profile ID>",
|
||||||
|
"annexId": "<annex ID>",
|
||||||
|
"customerId": "<customer ID>",
|
||||||
|
"name": "<display name>",
|
||||||
|
"cifType": "Individual",
|
||||||
|
"customerImage": "<image hash>",
|
||||||
|
"profileType": "0",
|
||||||
|
"color": "<hex color>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `profileType` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `"0"` | Individual (personal) |
|
||||||
|
| `"1"` | Sole Proprietor (business) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `accountBalance` Array
|
||||||
|
|
||||||
|
Each element represents one account:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cif": "<CIF number>",
|
||||||
|
"accountNumber": "<full account number>",
|
||||||
|
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
|
||||||
|
"template": "<display template ID>",
|
||||||
|
"currencyCode": "<ISO 4217 numeric>",
|
||||||
|
"currencyName": "<ISO 4217 alpha>",
|
||||||
|
"accountTypeName": "<account type label>",
|
||||||
|
"transfer": "Y",
|
||||||
|
"branchName": "<branch name>",
|
||||||
|
"availableBalance": "<decimal string>",
|
||||||
|
"currentBalance": "<decimal string>",
|
||||||
|
"blockedAmount": "<decimal string, may be negative>",
|
||||||
|
"settlementBalance": "<decimal string>",
|
||||||
|
"mvrBalance": "<MVR equivalent>",
|
||||||
|
"statusDesc": "Active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `accountNumber` | Full account number |
|
||||||
|
| `accountBriefName` | Human-readable account label |
|
||||||
|
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
|
||||||
|
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
|
||||||
|
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
|
||||||
|
| `availableBalance` | Spendable balance (decimal string) |
|
||||||
|
| `currentBalance` | Ledger balance (decimal string) |
|
||||||
|
| `blockedAmount` | Held/blocked funds — negative means funds are held |
|
||||||
|
| `settlementBalance` | Balance including pending settlements |
|
||||||
|
| `mvrBalance` | All balances converted to MVR for unified display |
|
||||||
|
| `transfer` | `"Y"` if usable as transfer source |
|
||||||
|
| `statusDesc` | Account status (e.g. `"Active"`) |
|
||||||
|
| `cif` | Customer Information File number |
|
||||||
|
| `template` | UI template ID |
|
||||||
|
|
||||||
|
> All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `accessRights`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"numAccounts": "<number of accounts>",
|
||||||
|
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12]",
|
||||||
|
"roleRights": "[]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`packageRights` is a JSON array encoded as a string — parse it separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Login Flow](02-login.md) **Next →** [Transaction History](04-history.md)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Transaction History
|
||||||
|
|
||||||
|
Fetch paginated transaction history for a single MIB account. Served from the WebView subdomain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxAccounts/trxHistory
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
See [README](README.md) for cookie and AJAX header format.
|
||||||
|
|
||||||
|
```
|
||||||
|
Referer: https://faisamobilex-wv.mib.com.mv//accountDetails?trxh=1&dashurl=1&accountNo=<accountNo>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Body (form-urlencoded)
|
||||||
|
|
||||||
|
| Field | Example | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `accountNo` | `90101000000001000` | Account number to fetch history for |
|
||||||
|
| `trxNo` | `` | Transaction number filter (empty = all) |
|
||||||
|
| `trxType` | `0` | Transaction type filter (`0` = all) |
|
||||||
|
| `sortTrx` | `date` | Sort field |
|
||||||
|
| `sortDir` | `desc` | Sort direction |
|
||||||
|
| `fromDate` | `` | From date filter (empty = no filter) |
|
||||||
|
| `toDate` | `` | To date filter (empty = no filter) |
|
||||||
|
| `start` | `1` | Start record index (1-based) |
|
||||||
|
| `end` | `20` | End record index (`start + pageSize - 1`) |
|
||||||
|
| `includeCount` | `1` | Include `total_count` in response |
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
Page size is 20. Compute `start`/`end` per page:
|
||||||
|
|
||||||
|
| Page | `start` | `end` |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `1` | `20` |
|
||||||
|
| 2 | `21` | `40` |
|
||||||
|
| N | `(N-1)*20 + 1` | `N*20` |
|
||||||
|
|
||||||
|
Stop when total fetched equals `total_count` or `data` is empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"total_count": "87",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"trxNumber": "TXN20260516001",
|
||||||
|
"trxDate": "2026-05-16",
|
||||||
|
"descr1": "Transfer Debit",
|
||||||
|
"baseAmount": "-500.00",
|
||||||
|
"curCodeDesc": "MVR",
|
||||||
|
"benefName": "Mohamed Ali",
|
||||||
|
"trxNumber2": "FT20260516001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trxNumber": "TXN20260515001",
|
||||||
|
"trxDate": "2026-05-15",
|
||||||
|
"descr1": "Transfer Credit",
|
||||||
|
"baseAmount": "1000.00",
|
||||||
|
"curCodeDesc": "MVR",
|
||||||
|
"benefName": "",
|
||||||
|
"trxNumber2": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` on success |
|
||||||
|
| `total_count` | `string` | Total transaction count — parse to `int` |
|
||||||
|
| `data` | `array` | Transactions for this page |
|
||||||
|
|
||||||
|
### Transaction Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `trxNumber` | `string` | Unique transaction ID |
|
||||||
|
| `trxDate` | `string` | Transaction date (`YYYY-MM-DD`) |
|
||||||
|
| `descr1` | `string` | Transaction description — trim whitespace |
|
||||||
|
| `baseAmount` | `string` | Decimal string — **negative = debit, positive = credit** |
|
||||||
|
| `curCodeDesc` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) |
|
||||||
|
| `benefName` | `string` | Counterparty name — blank or literal `"null"` means none |
|
||||||
|
| `trxNumber2` | `string` | Secondary reference; may be blank |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Accounts](03-accounts.md) **Next →** [Cards](05-cards.md)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Cards
|
||||||
|
|
||||||
|
Fetch debit card information for the authenticated session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
See [README](README.md) for cookie and AJAX header format.
|
||||||
|
|
||||||
|
```
|
||||||
|
Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Body (form-urlencoded)
|
||||||
|
|
||||||
|
| Field | Value | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `` | Card name filter (empty = all) |
|
||||||
|
| `start` | `1` | Start index (1-based) |
|
||||||
|
| `end` | `50` | End index |
|
||||||
|
| `includeCount` | `1` | Include total count |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"cardId": "CARD001",
|
||||||
|
"maskedCardNumber": "4111 **** **** 1234",
|
||||||
|
"cardStatus": "A",
|
||||||
|
"cardType": "D",
|
||||||
|
"cardTypeDesc": "Debit Card",
|
||||||
|
"customerId": "C000001",
|
||||||
|
"phoneNumber": "9600000001",
|
||||||
|
"cardHolderName": "MOHAMED ALI"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | `bool` | `true` on success |
|
||||||
|
| `data` | `array` | List of card objects |
|
||||||
|
|
||||||
|
### Card Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `cardId` | `string` | Internal card identifier |
|
||||||
|
| `maskedCardNumber` | `string` | Partially masked card number for display |
|
||||||
|
| `cardStatus` | `string` | Card status (`A` = Active) |
|
||||||
|
| `cardType` | `string` | Card type code (e.g. `D` = Debit) |
|
||||||
|
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"Debit Card"`) |
|
||||||
|
| `customerId` | `string` | Customer ID |
|
||||||
|
| `phoneNumber` | `string` | Registered phone number |
|
||||||
|
| `cardHolderName` | `string` | Name on card |
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Transaction History](04-history.md) **Next →** [Financing](06-financing.md)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Financing
|
||||||
|
|
||||||
|
Fetch active financing deals. Unlike other data endpoints, this returns an HTML page — deal data is embedded in `data-*` attributes on card elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
See [README](README.md) for cookie format.
|
||||||
|
|
||||||
|
### Additional Header
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-Requested-With` | `mv.com.mib.faisamobilex` |
|
||||||
|
|
||||||
|
Note: this endpoint uses the app package name as `X-Requested-With`, not `XMLHttpRequest`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
**Content-Type:** `text/html; charset=UTF-8`
|
||||||
|
|
||||||
|
Each financing deal is a `<div>` with class `finance-card-holder` and all fields as `data-*` attributes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="card border finance-card-holder"
|
||||||
|
data-productDesc = "Product Name"
|
||||||
|
data-dealStatus = "P"
|
||||||
|
data-statusDesc = "Approved"
|
||||||
|
data-dealAmount = "10000.00"
|
||||||
|
data-dealNo = "12345"
|
||||||
|
data-paidAmount = "2500.00"
|
||||||
|
data-outstandingAmount = "7500.00"
|
||||||
|
data-dealDate = "2024-01-15 00:00:00"
|
||||||
|
data-overdueAmount = "0"
|
||||||
|
data-installmentAmount = "500.00"
|
||||||
|
data-noOfInstallments = "24"
|
||||||
|
data-lastPaidDate = "2026-05-01 00:00:00"
|
||||||
|
data-lastPayAmount = "500.00"
|
||||||
|
data-financeCurrency = "462"
|
||||||
|
data-curCodeDesc = "MVR">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Fields
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `productDesc` | String | Product name (e.g. `"Ujalaa CG Finance"`) |
|
||||||
|
| `dealStatus` | String | Status code (`P` = Active/Pending) |
|
||||||
|
| `statusDesc` | String | Human-readable status (e.g. `"Approved"`) |
|
||||||
|
| `dealAmount` | Decimal | Total financing amount |
|
||||||
|
| `dealNo` | Integer | Unique deal/contract number |
|
||||||
|
| `paidAmount` | Decimal | Amount paid to date |
|
||||||
|
| `outstandingAmount` | Decimal | Remaining unpaid balance |
|
||||||
|
| `dealDate` | String | Contract start date (`yyyy-MM-dd HH:mm:ss`) |
|
||||||
|
| `overdueAmount` | Decimal | Amount currently overdue (`0` if none) |
|
||||||
|
| `installmentAmount` | Decimal | Monthly installment amount |
|
||||||
|
| `noOfInstallments` | Integer | Total number of installments |
|
||||||
|
| `lastPaidDate` | String | Date of most recent payment (`yyyy-MM-dd HH:mm:ss`) |
|
||||||
|
| `lastPayAmount` | Decimal | Amount of most recent payment |
|
||||||
|
| `financeCurrency` | Integer | Currency numeric code (`462` = MVR) |
|
||||||
|
| `curCodeDesc` | String | Currency abbreviation (e.g. `"MVR"`) |
|
||||||
|
|
||||||
|
### Parsing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val cardPattern = Regex("""finance-card-holder[^>]+>""")
|
||||||
|
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
|
||||||
|
```
|
||||||
|
|
||||||
|
Find all `finance-card-holder` elements, then extract `data-*` key/value pairs from each match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion Date Estimation
|
||||||
|
|
||||||
|
```
|
||||||
|
remainingInstallments = ceil(outstandingAmount / installmentAmount)
|
||||||
|
completionDate = today + remainingInstallments months
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No encryption — session maintained purely via cookies.
|
||||||
|
- The response is gzip/brotli compressed; OkHttp handles decompression automatically.
|
||||||
|
- `time-tracker=597` appears static — omitting it may affect behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Cards](05-cards.md) **Next →** [Personal Profile](07-profile.md)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Personal Profile
|
||||||
|
|
||||||
|
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://faisamobilex-wv.mib.com.mv/personalProfile
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Session cookies only — no additional AJAX headers required.
|
||||||
|
|
||||||
|
```
|
||||||
|
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
**Content-Type:** `text/html; charset=UTF-8`
|
||||||
|
|
||||||
|
The page contains an `<h5>` with the user's full name and `<span>` elements with labelled fields.
|
||||||
|
|
||||||
|
### Parsing Strategy
|
||||||
|
|
||||||
|
**Full name** — extracted from:
|
||||||
|
```html
|
||||||
|
<h5 class="mb-1 text-dark fw-semibold">Mohamed Ali</h5>
|
||||||
|
```
|
||||||
|
|
||||||
|
Regex:
|
||||||
|
```kotlin
|
||||||
|
Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Labelled fields** — each follows this pattern:
|
||||||
|
```html
|
||||||
|
<span ...><b ...>Username:</b ...>...<span ...>myusername</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Regex (used for each label):
|
||||||
|
```kotlin
|
||||||
|
Regex(
|
||||||
|
"""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||||
|
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extracted Fields
|
||||||
|
|
||||||
|
| Label in HTML | Field | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `Username:` | `username` | Login username |
|
||||||
|
| `Email:` | `email` | Registered email address |
|
||||||
|
| `Mobile no:` | `mobile` | Registered mobile number |
|
||||||
|
| `Enrolled:` | `enrolled` | Enrollment date or status |
|
||||||
|
|
||||||
|
Combined with the `fullName` from the `<h5>`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class MibPersonalProfile(
|
||||||
|
val fullName: String,
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val mobile: String,
|
||||||
|
val enrolled: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Returns `null` if the response cannot be parsed (network error or unexpected HTML structure).
|
||||||
|
- This endpoint does not have a JSON equivalent — scraping is the only method.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Financing](06-financing.md) **Next →** [Transfer](08-transfer.md)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Account Lookup & Transfer
|
||||||
|
|
||||||
|
Two-step process: look up the recipient to validate the account and get the holder name, then execute the transfer.
|
||||||
|
|
||||||
|
All endpoints are on the WebView subdomain. See [README](README.md) for cookie and AJAX header format.
|
||||||
|
|
||||||
|
```
|
||||||
|
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Account Lookup
|
||||||
|
|
||||||
|
The lookup endpoint depends on the format of the input:
|
||||||
|
|
||||||
|
| Input format | Endpoint | Body field |
|
||||||
|
|---|---|---|
|
||||||
|
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` |
|
||||||
|
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` |
|
||||||
|
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||||
|
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||||
|
| Contains `@` (email) | `AjaxAlias/getAlias` | `aliasName` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1a. IPS Account — BML / local bank (13 digits, starts with `7`)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: `benefAccount=7700000000000`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"responseCode": "2",
|
||||||
|
"accountName": "ACCOUNT HOLDER NAME",
|
||||||
|
"bankBic": "MALBMVMV"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `accountName` — account holder name
|
||||||
|
- `bankBic` — bank BIC (e.g. `MALBMVMV` for BML)
|
||||||
|
- Account number is the input itself — not returned in response
|
||||||
|
|
||||||
|
Use `bankNo=3` and `transferLocal` for the transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1b. MIB Internal Account (17 digits, starts with `9`)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: `accountNo=90100000000000000`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"accountName": "ACCOUNT HOLDER NAME"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `accountName` may be at root level or inside a `data` object — check both
|
||||||
|
- Bank is always MIB (`MADVMVMV`)
|
||||||
|
|
||||||
|
Use `bankNo=2` and `transferInternal` for the transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1c. Favara Alias — shortcodes, A-IDs, emails
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: `aliasName=<alias>`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"responseCode": "2",
|
||||||
|
"data": {
|
||||||
|
"BfyNm": "Account Holder Name",
|
||||||
|
"CdtrAcct": {
|
||||||
|
"Acct": "90100000000000000",
|
||||||
|
"FinInstnId": "MADVMVMV"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `BfyNm` — beneficiary name (trim whitespace)
|
||||||
|
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
||||||
|
- `CdtrAcct.FinInstnId` — bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML)
|
||||||
|
|
||||||
|
Use `bankNo=2` (MIB) or `3` (BML/local) depending on `FinInstnId`, and the matching transfer endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lookup Errors
|
||||||
|
|
||||||
|
All three lookup endpoints return `success: false` with a human-readable `reasonText` on failure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"reasonText": "Account not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always show `reasonText` directly to the user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Execute Transfer
|
||||||
|
|
||||||
|
Two endpoints depending on the destination bank:
|
||||||
|
|
||||||
|
| `bankNo` | Endpoint | Destination |
|
||||||
|
|---|---|---|
|
||||||
|
| `2` | `ajaxTransfer/transferInternal` | MIB internal account |
|
||||||
|
| `3` | `ajaxTransfer/transferLocal` | BML or other local bank |
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferInternal
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferLocal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Body (form-urlencoded)
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `benefName` | Recipient name (from lookup) |
|
||||||
|
| `benefNo` | `0` (not a saved contact) |
|
||||||
|
| `fromAccountNo` | Source account number |
|
||||||
|
| `benefAccountNo` | Destination account number |
|
||||||
|
| `transferCy` | Currency numeric code (`"462"` = MVR, `"840"` = USD) |
|
||||||
|
| `benefCurrencyCode` | Same as `transferCy` |
|
||||||
|
| `amount` | Amount as string (e.g. `"100.00"`) |
|
||||||
|
| `bankNo` | `2` = MIB internal, `3` = local/BML |
|
||||||
|
| `purpose` | Transfer purpose; send `"-"` if blank |
|
||||||
|
| `otp` | OTP from the user |
|
||||||
|
| `otpType` | `"3"` (SMS/authenticator OTP) |
|
||||||
|
|
||||||
|
### Response — Success
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"trxId": "TRX20260516001",
|
||||||
|
"date": "2026-05-16 15:10:25"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `trxId` | Transaction ID |
|
||||||
|
| `date` | Completion timestamp |
|
||||||
|
|
||||||
|
### Response — Failure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"reasonText": "Insufficient balance"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reasonText` contains the error reason. On HTTP `419`, the session has expired — re-login required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Type Summary
|
||||||
|
|
||||||
|
| Recipient | Lookup endpoint | `bankNo` | Transfer endpoint |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MIB (17-digit `9…`) | `getAccountName` | `2` | `transferInternal` |
|
||||||
|
| BML (13-digit `7…`) | `getIPSAccount` | `3` | `transferLocal` |
|
||||||
|
| Favara alias → MIB | `getAlias` | `2` | `transferInternal` |
|
||||||
|
| Favara alias → BML | `getAlias` | `3` | `transferLocal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Currency Codes
|
||||||
|
|
||||||
|
| `transferCy` | Currency |
|
||||||
|
|---|---|
|
||||||
|
| `"462"` | MVR (Maldivian Rufiyaa) |
|
||||||
|
| `"840"` | USD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Personal Profile](07-profile.md) **Next →** [Contacts](09-contacts.md)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Contacts (Beneficiaries)
|
||||||
|
|
||||||
|
Manage the user's saved beneficiary list. All endpoints use WebView session auth — see [README](README.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Categories
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty POST body.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "id": "100001", "categoryName": "Myself", "numBenef": "2" },
|
||||||
|
{ "id": "100002", "categoryName": "Friends", "numBenef": "10" },
|
||||||
|
{ "id": "100003", "categoryName": "Business", "numBenef": "8" },
|
||||||
|
{ "id": "100004", "categoryName": "Family", "numBenef": "5" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Category ID — use as `searchCategoryId` when filtering contacts |
|
||||||
|
| `categoryName` | Display name |
|
||||||
|
| `numBenef` | Number of beneficiaries in this category (string) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Body (form-urlencoded)
|
||||||
|
|
||||||
|
| Field | Example | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `page` | `1` | Page number (1-based) |
|
||||||
|
| `search` | `` | Search query (empty = all) |
|
||||||
|
| `searchCategoryId` | `0` | Category filter (`0` = all) |
|
||||||
|
| `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
|
||||||
|
| `sortBenef` | `name` | Sort field |
|
||||||
|
| `sortDir` | `asc` | Sort direction |
|
||||||
|
| `start` | `1` | Start record index (1-based) |
|
||||||
|
| `end` | `100` | End record index |
|
||||||
|
| `includeCount` | `1` | Include `total_count` |
|
||||||
|
|
||||||
|
### Beneficiary Types
|
||||||
|
|
||||||
|
| `benefType` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `I` | Internal (MIB to MIB) |
|
||||||
|
| `L` | Local (other Maldivian banks, e.g. BML) |
|
||||||
|
| `S` | Swift (international) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"total_count": "48",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"benefNo": "100001",
|
||||||
|
"benefName": "Person Name",
|
||||||
|
"benefNickName": "Nickname",
|
||||||
|
"benefAccount": "7700000000001",
|
||||||
|
"benefType": "L",
|
||||||
|
"bankColor": "#AC0000",
|
||||||
|
"benefBankName": "Bank of Maldives PLC",
|
||||||
|
"bankCode": "BML",
|
||||||
|
"benefSwiftCode": "MALBMVMV",
|
||||||
|
"benefStatus": "A",
|
||||||
|
"benefBankId": "3",
|
||||||
|
"transferCy": "462",
|
||||||
|
"transferCyDesc": "MVR",
|
||||||
|
"customerImgHash": "abcd1234hash",
|
||||||
|
"benefCategoryID": "100002"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `benefNo` | Unique beneficiary ID — use for delete |
|
||||||
|
| `benefNickName` | User-assigned nickname (prefer over `benefName` for display) |
|
||||||
|
| `benefType` | `L`, `I`, or `S` |
|
||||||
|
| `bankColor` | Hex color for placeholder avatar background |
|
||||||
|
| `customerImgHash` | Hash for fetching profile photo (`null` if no photo) |
|
||||||
|
| `benefCategoryID` | Category ID — `"0"` means uncategorized |
|
||||||
|
| `transferCyDesc` | Currency (e.g. `"MVR"`, `"USD"`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get Profile Image
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: `imageHash=<customerImgHash>`
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"profileImage": "<base64-encoded JPEG>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`profileImage` is raw base64 JPEG with no data URI prefix. Decode with `Base64.decode(value, Base64.DEFAULT)` then `BitmapFactory.decodeByteArray(...)`. Cache decoded bitmaps — the same hash may appear across multiple contacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create Contact
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/createLocalBeneficiary
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary/createNew
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Body (form-urlencoded)
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `benefType` | `"I"` = MIB internal, `"L"` = local/BML |
|
||||||
|
| `benefAccount` | Beneficiary account number |
|
||||||
|
| `benefName` | Full name |
|
||||||
|
| `nickName` | Display nickname |
|
||||||
|
| `bankNo` | `2` = MIB, `3` = BML/local |
|
||||||
|
| `transferCy` | Currency numeric code (`"462"` = MVR) |
|
||||||
|
| `categoryId` | Category ID (`"0"` = uncategorized) |
|
||||||
|
| `imageSet` | `"1"` if image provided, `"0"` otherwise |
|
||||||
|
| `image` | Base64-encoded image (empty string if none) |
|
||||||
|
| `benefIban` | Leave blank |
|
||||||
|
| `benefAddress` | Leave blank |
|
||||||
|
| `benefCity` | Leave blank |
|
||||||
|
| `benefCountry` | `"4"` |
|
||||||
|
| `benefBankSwift` | Leave blank |
|
||||||
|
| `benefBankName` | Leave blank |
|
||||||
|
| `benefBankBranch` | Leave blank |
|
||||||
|
| `benefBankAddress` | Leave blank |
|
||||||
|
| `benefBankCity` | Leave blank |
|
||||||
|
| `benefBankCountry` | `"4"` |
|
||||||
|
| `intBankSwift` | Leave blank |
|
||||||
|
| `intBankName` | Leave blank |
|
||||||
|
| `intBankAddress` | Leave blank |
|
||||||
|
| `intBankBranch` | Leave blank |
|
||||||
|
| `intBankCity` | Leave blank |
|
||||||
|
| `intBankCountry` | `"4"` |
|
||||||
|
| `transferCySwift` | `"840"` (USD numeric — static) |
|
||||||
|
| `email` | Leave blank |
|
||||||
|
| `contactNumber` | Leave blank |
|
||||||
|
| `website` | Leave blank |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`success: true` confirms the contact was saved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delete Contact
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/deleteBeneficiary
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: `benefNo=<benefNo>`
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact Stats
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty POST body.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{ "type": "L", "count": "30" },
|
||||||
|
{ "type": "I", "count": "10" },
|
||||||
|
{ "type": "S", "count": "2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Gives counts per beneficiary type. Useful for showing tab badges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Transfer](08-transfer.md)
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# MIB Faisanet — List Accounts & Balances
|
|
||||||
|
|
||||||
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`).
|
|
||||||
The login initialization call (`A41`) returns an empty `accountBalance` array until a profile is selected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flow to Get Account Balances
|
|
||||||
|
|
||||||
```
|
|
||||||
[0] sfunc=i (key1) → DH key exchange → derive session_key
|
|
||||||
[1] sfunc=n A44 → get userSalt
|
|
||||||
[2] sfunc=n A41 → login with password → returns operatingProfiles (no balances yet)
|
|
||||||
[3] sfunc=n A42 → OTP verify
|
|
||||||
[4] sfunc=n P47 → select profile → returns accountBalance array
|
|
||||||
```
|
|
||||||
|
|
||||||
Steps 0–3 are the standard login flow (see `LOGIN_FLOW.md`). Step 4 is the new call.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1 — Get Profile List from A41 Response
|
|
||||||
|
|
||||||
The `A41` login initialization response includes `operatingProfiles` — the list of
|
|
||||||
profiles available to the user (personal, business, etc.).
|
|
||||||
|
|
||||||
**Relevant fields from A41 response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"defaultProfile": "2",
|
|
||||||
"operatingProfiles": [
|
|
||||||
{
|
|
||||||
"profileId": "<profile ID>",
|
|
||||||
"customerProfileId": "<customer profile ID>",
|
|
||||||
"annexId": "<annex ID>",
|
|
||||||
"customerId": "<customer ID>",
|
|
||||||
"name": "<profile display name>",
|
|
||||||
"cifType": "Individual",
|
|
||||||
"customerImage": "<image hash>",
|
|
||||||
"profileType": "0",
|
|
||||||
"color": "<hex color>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"profileId": "<profile ID>",
|
|
||||||
"customerProfileId": "<customer profile ID>",
|
|
||||||
"annexId": "<annex ID>",
|
|
||||||
"customerId": "<customer ID>",
|
|
||||||
"name": "<business name / owner name>",
|
|
||||||
"cifType": "Sole Propr",
|
|
||||||
"customerImage": "<image hash>",
|
|
||||||
"profileType": "1",
|
|
||||||
"color": "<hex color>"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"selectedProfileId": null,
|
|
||||||
"selectedProfileType": null,
|
|
||||||
"profileSelected": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`profileType` values observed:
|
|
||||||
| Value | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `"0"` | Individual (personal) |
|
|
||||||
| `"1"` | Sole Proprietor (business) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Select Profile (`sfunc=n`, `routePath: P47`)
|
|
||||||
|
|
||||||
**Key**: session key (derived from `sfunc=i` response)
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"profileType": "<profileType from operatingProfiles>",
|
|
||||||
"profileId": "<profileId from operatingProfiles>",
|
|
||||||
"nonce": "<computed nonce>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random 20-bit int>",
|
|
||||||
"routePath": "P47",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "101",
|
|
||||||
"reasonText": "Profile Selected!",
|
|
||||||
"landingPage": "0",
|
|
||||||
"accountBalance": [ ... ],
|
|
||||||
"accessRights": { ... },
|
|
||||||
"services": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## accountBalance Array
|
|
||||||
|
|
||||||
Each element in `accountBalance` represents one account:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cif": "<CIF number>",
|
|
||||||
"accountNumber": "<full account number>",
|
|
||||||
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
|
|
||||||
"template": "<display template ID>",
|
|
||||||
"currencyCode": "<ISO 4217 numeric code>",
|
|
||||||
"currencyName": "<ISO 4217 alpha code>",
|
|
||||||
"accountTypeName": "<account type label>",
|
|
||||||
"transfer": "Y",
|
|
||||||
"branchName": "<branch name>",
|
|
||||||
"availableBalance": "<decimal string>",
|
|
||||||
"currentBalance": "<decimal string>",
|
|
||||||
"blockedAmount": "<decimal string, may be negative>",
|
|
||||||
"settlementBalance": "<decimal string>",
|
|
||||||
"mvrBalance": "<MVR equivalent as decimal string>",
|
|
||||||
"statusDesc": "Active"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Field reference
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|---|---|
|
|
||||||
| `accountNumber` | Full account number |
|
|
||||||
| `accountBriefName` | Human-readable account label |
|
|
||||||
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
|
|
||||||
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
|
|
||||||
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
|
|
||||||
| `availableBalance` | Spendable balance |
|
|
||||||
| `currentBalance` | Ledger balance |
|
|
||||||
| `blockedAmount` | Held/blocked funds (negative means funds are held) |
|
|
||||||
| `settlementBalance` | Balance including pending settlements |
|
|
||||||
| `mvrBalance` | All balances converted to MVR for display |
|
|
||||||
| `transfer` | `"Y"` if account can be used as transfer source |
|
|
||||||
| `statusDesc` | Account status (e.g. `"Active"`) |
|
|
||||||
| `cif` | Customer Information File number |
|
|
||||||
| `template` | UI template ID (controls how card is rendered in-app) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## accessRights
|
|
||||||
|
|
||||||
Also returned in the P47 response, describes what the selected profile can do:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"numAccounts": "<number of accounts>",
|
|
||||||
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12,...]",
|
|
||||||
"roleRights": "[]"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`packageRights` is a JSON array encoded as a string — parse it separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `accountBalance` in the `A41` response is always `[]`. Balances are only returned after `P47`.
|
|
||||||
- To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
|
|
||||||
- `mvrBalance` is always in MVR regardless of the account's native currency, useful for showing a unified total.
|
|
||||||
- All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
# Faisanet MIB API Documentation
|
|
||||||
|
|
||||||
Reverse-engineered from `mv.com.mib.faisamobilex` (React Native, Hermes bytecode v96).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Base
|
|
||||||
|
|
||||||
- **URL**: `https://faisanet.mib.com.mv/faisamobilex_smvc/`
|
|
||||||
- **Method**: `POST /`
|
|
||||||
- **Content-Type**: `application/x-www-form-urlencoded; charset=utf-8`
|
|
||||||
- **User-Agent**: `android/1.0`
|
|
||||||
- **Accept**: `application/json`
|
|
||||||
|
|
||||||
All requests share the same form body structure:
|
|
||||||
```
|
|
||||||
sfunc=<function_code>&data=<urlencode(blowfish_ecb_base64_ciphertext)>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Encryption
|
|
||||||
|
|
||||||
### Algorithm
|
|
||||||
- **Cipher**: Blowfish, ECB mode, PKCS5 padding
|
|
||||||
- **Input**: raw UTF-8 bytes of JSON payload string
|
|
||||||
- **Key**: raw UTF-8 bytes of key string
|
|
||||||
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
|
|
||||||
|
|
||||||
### Python equivalent
|
|
||||||
```python
|
|
||||||
from Crypto.Cipher import Blowfish
|
|
||||||
from Crypto.Util.Padding import pad, unpad
|
|
||||||
import base64
|
|
||||||
|
|
||||||
def encrypt(payload: dict, key: str) -> str:
|
|
||||||
import json
|
|
||||||
plaintext = json.dumps(payload).encode()
|
|
||||||
key_bytes = key.encode('latin-1')
|
|
||||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
|
||||||
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
|
|
||||||
return base64.b64encode(ct).decode()
|
|
||||||
|
|
||||||
def decrypt(ciphertext_b64: str, key: str) -> dict:
|
|
||||||
import json
|
|
||||||
key_bytes = key.encode('latin-1')
|
|
||||||
ct = base64.b64decode(ciphertext_b64)
|
|
||||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
|
||||||
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
|
|
||||||
return json.loads(plaintext.decode())
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key lifecycle
|
|
||||||
|
|
||||||
| Phase | Key used |
|
|
||||||
|---|---|
|
|
||||||
| `sfunc=r` (key exchange) | `DEFAULT_KEY` (hardcoded in app) |
|
|
||||||
| All subsequent requests | DH-derived session key |
|
|
||||||
|
|
||||||
**DEFAULT_KEY** (hardcoded):
|
|
||||||
```
|
|
||||||
8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Diffie-Hellman Key Exchange
|
|
||||||
|
|
||||||
The app uses a **custom Diffie-Hellman** scheme to derive a session key.
|
|
||||||
|
|
||||||
### Fixed parameters (hardcoded in app)
|
|
||||||
|
|
||||||
> Note: the variable names in the app's source are swapped from their DH role.
|
|
||||||
> `A_VALUE` in the source is the **exponent** (shorter number), `P_VALUE` is the **prime modulus** (longer number).
|
|
||||||
|
|
||||||
```
|
|
||||||
G (generator) = 2
|
|
||||||
A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
|
||||||
P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note**: `A` (client private key) is hardcoded in the app — this DH provides no real security.
|
|
||||||
|
|
||||||
### Session key derivation
|
|
||||||
```python
|
|
||||||
import hashlib, base64
|
|
||||||
|
|
||||||
def derive_session_key(smod: int) -> str:
|
|
||||||
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
|
||||||
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
|
||||||
shared_secret = pow(smod, A, P)
|
|
||||||
sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper()
|
|
||||||
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
|
|
||||||
```
|
|
||||||
|
|
||||||
The resulting session key is always a **44-character base64 string** (32 bytes / 256-bit SHA-256 output), for example:
|
|
||||||
```
|
|
||||||
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
|
|
||||||
```
|
|
||||||
It changes every session because `smod` is different each time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoints (sfunc values)
|
|
||||||
|
|
||||||
### `r` — Key Exchange (initiate session)
|
|
||||||
|
|
||||||
**Request payload** (encrypted with DEFAULT_KEY):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "r",
|
|
||||||
"data": {
|
|
||||||
"cmod": "<G^A mod P as decimal string>",
|
|
||||||
"appId": "IOS17.2-<random 15-char string>",
|
|
||||||
"routePath": "S40",
|
|
||||||
"sodium": "<random 20-bit integer as string>",
|
|
||||||
"xxid": "<random 40-bit integer as string>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response payload** (encrypted with DEFAULT_KEY):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "1",
|
|
||||||
"reasonCode": "201",
|
|
||||||
"reasonText": "Key generated successfully.",
|
|
||||||
"smod": "<server DH public key as decimal string>",
|
|
||||||
"nonceGenerator": "<instruction string for nonce computation>",
|
|
||||||
"xxid": "<session token>",
|
|
||||||
"sodium": "<server random>",
|
|
||||||
"encMethod": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After this call:
|
|
||||||
- Compute `encryptionKey = derive_session_key(int(smod))`
|
|
||||||
- Store `xxid` and `nonceGenerator` for subsequent calls
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Request envelope structure
|
|
||||||
|
|
||||||
All requests after key exchange use this structure:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "<function_code>",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"nonce": "<computed from nonceGenerator>",
|
|
||||||
"appId": "<same appId>",
|
|
||||||
"sodium": "<random 20-bit>",
|
|
||||||
"routePath": "<route constant>",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
...additional fields...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Encrypted with the DH-derived `encryptionKey`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Login Flows
|
|
||||||
|
|
||||||
### First-time device registration (no stored key1/key2)
|
|
||||||
|
|
||||||
1. `sfunc=r` → `S40` — DH key exchange with `DEFAULT_KEY` → receive `xxid`, `nonceGenerator`, `smod` → derive session key
|
|
||||||
2. `sfunc=n` → `A44` — get `userSalt` for username
|
|
||||||
3. `sfunc=n` → `C41` — submit `pgf03` (computed from password + userSalt + random clientSalt)
|
|
||||||
4. `sfunc=n` → `C42` — verify TOTP OTP → receive `key1` and `key2`; persist them
|
|
||||||
5. Continue with regular login below (using the just-received key1/key2)
|
|
||||||
|
|
||||||
### Regular login (stored key1/key2 present)
|
|
||||||
|
|
||||||
1. `sfunc=i` → `S40` — DH key exchange with `key1`, sending `key2` as extra form field → derive session key
|
|
||||||
2. `sfunc=n` → `A44` — get `userSalt` for username
|
|
||||||
3. `sfunc=n` → `A41` — submit `pgf03` → receive `operatingProfiles` list
|
|
||||||
4. For each profile: `sfunc=n` → `P47` — fetch `accountBalance` array
|
|
||||||
|
|
||||||
> **No A42 step in regular login.** OTP is only verified once during first-time registration (C42).
|
|
||||||
|
|
||||||
### pgf03 formula
|
|
||||||
|
|
||||||
```python
|
|
||||||
h1 = SHA256(password).hexdigest().upper()
|
|
||||||
h2 = SHA256(h1 + userSalt).hexdigest().upper()
|
|
||||||
pgf03 = SHA256(clientSalt + h2).hexdigest().upper()
|
|
||||||
```
|
|
||||||
|
|
||||||
`clientSalt` is a random 32-character alphanumeric string generated fresh each login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known route paths
|
|
||||||
|
|
||||||
| sfunc | routePath | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `r` | `S40` | DH key exchange (first-time registration) |
|
|
||||||
| `i` | `S40` | DH key exchange (regular login, sends `key1`/`key2`) |
|
|
||||||
| `n` | `A44` | Get auth type — returns `userSalt` for the given `uname` |
|
|
||||||
| `n` | `C41` | Registration: submit credentials (`uname`, `pgf03`, `clientSalt`) |
|
|
||||||
| `n` | `C42` | Registration: verify OTP (`otp`, `uname`, `otpType=3`) — returns `key1`/`key2` |
|
|
||||||
| `n` | `A41` | Login: submit credentials (`uname`, `pgf03`, `clientSalt`, `pmodTime`, `requireBankData`) — returns `operatingProfiles` |
|
|
||||||
| `n` | `P47` | Fetch account balances for a profile (`profileType`, `profileId`) — returns `accountBalance` array |
|
|
||||||
| `n` | `P40` | Update profile image |
|
|
||||||
| `n` | `P42` | Delete profile image |
|
|
||||||
|
|
||||||
> Note: `A42` (login OTP verify) is **not sent** during regular login. It was present in an older flow but is no longer used. `C42` is only sent during first-time device registration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nonce Computation
|
|
||||||
|
|
||||||
Every request after key exchange includes a `nonce` field computed from the `nonceGenerator`
|
|
||||||
string returned by the key exchange response.
|
|
||||||
|
|
||||||
### nonceGenerator format
|
|
||||||
|
|
||||||
A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens.
|
|
||||||
Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
|
|
||||||
|
|
||||||
```
|
|
||||||
M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nonce output format
|
|
||||||
|
|
||||||
4 groups separated by `-`. Each group: a zero-padded 5-digit number followed by 7 two-digit
|
|
||||||
numbers separated by spaces.
|
|
||||||
|
|
||||||
```
|
|
||||||
08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
|
|
||||||
```
|
|
||||||
|
|
||||||
### Algorithm
|
|
||||||
|
|
||||||
**Phase 1 — process first token of each group (produces seed values):**
|
|
||||||
|
|
||||||
For each of the 4 groups (index `i`):
|
|
||||||
1. Take `token[0]` (e.g. `M85`). Extract the number: `N = parseInt(token.replace(/\D/g, ''))`.
|
|
||||||
2. Generate a random integer: `r = floor(random() * 99) + 1` (range 1–99 inclusive).
|
|
||||||
3. Compute `product = N * r`. Zero-pad to 5 digits: `padded = product.toString().padStart(5, '0')`.
|
|
||||||
4. Compute `digitSum[i]` = sum of all digits in `padded`.
|
|
||||||
5. Store `lastTwo[i]` = `parseInt(padded.slice(-2))` (last two digits as integer).
|
|
||||||
6. Accumulate `cumSum += digitSum[i]`.
|
|
||||||
|
|
||||||
After all 4 groups: `cumSum` = sum of all four `digitSum` values.
|
|
||||||
|
|
||||||
**Phase 2 — process tokens 1–7 of each group (produces nonce digits):**
|
|
||||||
|
|
||||||
For each group (index `i`), process `token[1]` through `token[7]`:
|
|
||||||
- Initialise `carry = lastTwo[i]`.
|
|
||||||
- For each token at position `j` (1–7):
|
|
||||||
- Extract letter `op` and number `num`.
|
|
||||||
- Compute `val` based on `op`:
|
|
||||||
| op | formula |
|
|
||||||
|---|---|
|
|
||||||
| `M` | `(carry % num) + digitSum[i] + cumSum` |
|
|
||||||
| `A` | `carry + num + digitSum[i] + cumSum` |
|
|
||||||
| `S` | `(carry * carry) + num + digitSum[i] + cumSum` |
|
|
||||||
| `X` | `(carry * num) + digitSum[i] + cumSum` |
|
|
||||||
| `C` | `(carry * carry * carry) + num + digitSum[i] + cumSum` |
|
|
||||||
- Nonce digit = `parseInt(val.toString().slice(-2))` (last two digits as integer).
|
|
||||||
- Update `carry = nonceDigit` for the next token.
|
|
||||||
|
|
||||||
**Assembling the nonce string:**
|
|
||||||
|
|
||||||
For each group `i`:
|
|
||||||
```
|
|
||||||
group_str = padded[i] + " " + nonceDigit[i][0].toString().padStart(2,'0') + " " + ... (7 digits)
|
|
||||||
```
|
|
||||||
Join 4 groups with `-`.
|
|
||||||
|
|
||||||
### Python implementation
|
|
||||||
|
|
||||||
```python
|
|
||||||
import math, random
|
|
||||||
|
|
||||||
def generate_nonce(nonce_generator: str) -> str:
|
|
||||||
groups = nonce_generator.split('-')
|
|
||||||
|
|
||||||
padded_list, last_two, digit_sum = [], [], []
|
|
||||||
cum_sum = 0
|
|
||||||
|
|
||||||
# Phase 1
|
|
||||||
for group in groups:
|
|
||||||
tokens = group.split(' ')
|
|
||||||
n = int(''.join(c for c in tokens[0] if c.isdigit()))
|
|
||||||
r = math.floor(random.random() * 99) + 1
|
|
||||||
product = n * r
|
|
||||||
padded = str(product).zfill(5)
|
|
||||||
ds = sum(int(d) for d in padded)
|
|
||||||
lt = int(padded[-2:])
|
|
||||||
padded_list.append(padded)
|
|
||||||
last_two.append(lt)
|
|
||||||
digit_sum.append(ds)
|
|
||||||
cum_sum += ds
|
|
||||||
|
|
||||||
# Phase 2
|
|
||||||
result_groups = []
|
|
||||||
for i, group in enumerate(groups):
|
|
||||||
tokens = group.split(' ')
|
|
||||||
carry = last_two[i]
|
|
||||||
ds = digit_sum[i]
|
|
||||||
nonce_digits = []
|
|
||||||
for token in tokens[1:]:
|
|
||||||
op = ''.join(c for c in token if c.isalpha())
|
|
||||||
num = int(''.join(c for c in token if c.isdigit()))
|
|
||||||
if op == 'M':
|
|
||||||
val = (carry % num) + ds + cum_sum
|
|
||||||
elif op == 'A':
|
|
||||||
val = carry + num + ds + cum_sum
|
|
||||||
elif op == 'S':
|
|
||||||
val = (carry * carry) + num + ds + cum_sum
|
|
||||||
elif op == 'X':
|
|
||||||
val = (carry * num) + ds + cum_sum
|
|
||||||
elif op == 'C':
|
|
||||||
val = (carry * carry * carry) + num + ds + cum_sum
|
|
||||||
else:
|
|
||||||
val = 0
|
|
||||||
digit = int(str(val)[-2:])
|
|
||||||
nonce_digits.append(digit)
|
|
||||||
carry = digit
|
|
||||||
group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
|
|
||||||
result_groups.append(group_str)
|
|
||||||
|
|
||||||
return '-'.join(result_groups)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- `nonce` and `sodium` are **separate** request fields. `sodium` is an independent random integer
|
|
||||||
(observed range ~1M–16M, approximately 23–24 bit).
|
|
||||||
- The nonce string is the same value for both the `nonce` and ... actually they are different fields:
|
|
||||||
`nonce` = the computed nonce string; `sodium` = a random integer sent as a plain string.
|
|
||||||
- For `sfunc=i`, `key2` is sent as a **separate form field** (not inside the encrypted payload):
|
|
||||||
`key2=<key2>&sfunc=i&data=<encrypted>`. The encrypted payload is the inner data object only,
|
|
||||||
encrypted with `key1`.
|
|
||||||
- For all `sfunc=n` requests (every request after key exchange), `xxid` is sent as a **separate
|
|
||||||
unencrypted form field** as the FIRST field:
|
|
||||||
`xxid=<session_xxid>&sfunc=n&data=<encrypted>`. The `xxid` also appears inside the encrypted
|
|
||||||
payload. Field order matters — `xxid` must come before `sfunc` and `data`.
|
|
||||||
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
# MIB Faisanet API — Encryption & Decryption
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
All API traffic is encrypted using **Blowfish** in ECB mode with PKCS5 padding.
|
|
||||||
Every request and response body is a single base64-encoded Blowfish ciphertext.
|
|
||||||
|
|
||||||
There are two keys in play:
|
|
||||||
|
|
||||||
| Key | Used for |
|
|
||||||
|---|---|
|
|
||||||
| `DEFAULT_KEY` (hardcoded) | The initial key exchange request and response (`sfunc=r`) |
|
|
||||||
| Session key (DH-derived) | Every request and response after the key exchange |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The DEFAULT_KEY
|
|
||||||
|
|
||||||
```
|
|
||||||
8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
|
|
||||||
```
|
|
||||||
|
|
||||||
This key is hardcoded in the app's JavaScript bundle. It is only used for the
|
|
||||||
first call (`sfunc=r`) which establishes a session key via Diffie-Hellman.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Session Key Derivation (Diffie-Hellman)
|
|
||||||
|
|
||||||
The app uses a custom DH key exchange to derive a per-session Blowfish key.
|
|
||||||
All three DH parameters are hardcoded in the app:
|
|
||||||
|
|
||||||
```
|
|
||||||
G = 2
|
|
||||||
P = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
|
||||||
A = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
|
||||||
```
|
|
||||||
|
|
||||||
`A` is the client's private key. Because it is hardcoded and never rotates,
|
|
||||||
anyone with the APK can derive the session key from a captured `smod`.
|
|
||||||
|
|
||||||
### Step-by-step
|
|
||||||
|
|
||||||
1. Client computes `cmod = G^A mod P` and sends it in the `sfunc=r` request.
|
|
||||||
2. Server computes its own keypair and responds with `smod` (its public key).
|
|
||||||
3. Client computes the shared secret: `shared = smod^A mod P`
|
|
||||||
4. Client SHA-256 hashes the decimal string of the shared secret (uppercased hex).
|
|
||||||
5. Client converts that hex string to raw bytes, then base64-encodes it.
|
|
||||||
6. The result is the Blowfish key for the rest of the session.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import hashlib, base64
|
|
||||||
|
|
||||||
def derive_session_key(smod: int) -> str:
|
|
||||||
# A_VALUE in app = exponent (shorter), P_VALUE in app = modulus (longer)
|
|
||||||
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
|
||||||
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
|
||||||
shared = pow(smod, A, P)
|
|
||||||
sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
|
|
||||||
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Encrypting a Request
|
|
||||||
|
|
||||||
All request payloads follow this JSON structure before encryption:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "<function code>",
|
|
||||||
"xxid": "<session token>",
|
|
||||||
"data": {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Encryption steps
|
|
||||||
|
|
||||||
1. `JSON.stringify` the payload.
|
|
||||||
2. Use the raw UTF-8 bytes of the payload as plaintext.
|
|
||||||
3. Use the raw UTF-8 bytes of the key string as the Blowfish key.
|
|
||||||
4. Encrypt: Blowfish / ECB / PKCS5 padding.
|
|
||||||
5. Base64-encode the ciphertext.
|
|
||||||
6. URL-encode the base64 string.
|
|
||||||
7. Send as form field: `sfunc=<value>&data=<url-encoded-ciphertext>`
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json, base64
|
|
||||||
from urllib.parse import quote
|
|
||||||
from Crypto.Cipher import Blowfish
|
|
||||||
from Crypto.Util.Padding import pad
|
|
||||||
|
|
||||||
def encrypt(payload: dict, key: str) -> str:
|
|
||||||
plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
|
||||||
key_bytes = key.encode('latin-1')
|
|
||||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
|
||||||
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
|
|
||||||
return base64.b64encode(ct).decode()
|
|
||||||
|
|
||||||
def build_request_body(payload: dict, key: str) -> str:
|
|
||||||
sfunc = payload.get('sfunc', '')
|
|
||||||
encrypted = encrypt(payload, key)
|
|
||||||
return f"sfunc={sfunc}&data={quote(encrypted)}"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decrypting a Response
|
|
||||||
|
|
||||||
The response body is a raw base64-encoded Blowfish ciphertext (no form encoding).
|
|
||||||
|
|
||||||
### Decryption steps
|
|
||||||
|
|
||||||
1. Base64-decode the response body to get the ciphertext bytes.
|
|
||||||
2. Decrypt with Blowfish / ECB / PKCS5 padding using the appropriate key.
|
|
||||||
3. Parse the result as JSON.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json, base64
|
|
||||||
from Crypto.Cipher import Blowfish
|
|
||||||
from Crypto.Util.Padding import unpad
|
|
||||||
|
|
||||||
def decrypt(ciphertext_b64: str, key: str) -> dict:
|
|
||||||
key_bytes = key.encode('latin-1')
|
|
||||||
ct = base64.b64decode(ciphertext_b64)
|
|
||||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
|
||||||
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
|
|
||||||
return json.loads(plaintext.decode('utf-8'))
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Session Example
|
|
||||||
|
|
||||||
### 1. Send key exchange (`sfunc=r`) — use DEFAULT_KEY
|
|
||||||
|
|
||||||
Request form body:
|
|
||||||
```
|
|
||||||
sfunc=r&data=<base64 of Blowfish(DEFAULT_KEY, {"sfunc":"r","data":{"cmod":"...","appId":"...","routePath":"S40","sodium":"...","xxid":"..."}})>
|
|
||||||
```
|
|
||||||
|
|
||||||
Response (decrypted with DEFAULT_KEY):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"smod": "<large decimal integer — server DH public key>",
|
|
||||||
"nonceGenerator": "<instruction string, e.g. 'M26 C16 C4 C5 M64 ...'>",
|
|
||||||
"xxid": "<session token>",
|
|
||||||
"sodium": "<server random hex string>",
|
|
||||||
"encMethod": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Derive the session key from `smod`
|
|
||||||
|
|
||||||
```python
|
|
||||||
session_key = derive_session_key(int(response['smod']))
|
|
||||||
# → a 44-character base64 string, e.g. "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX="
|
|
||||||
# The key is 32 bytes (256-bit SHA-256 output) encoded as base64.
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. All subsequent requests — use session key
|
|
||||||
|
|
||||||
Encrypt with `session_key`, decrypt responses with `session_key`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick reference
|
|
||||||
|
|
||||||
| What | How |
|
|
||||||
|---|---|
|
|
||||||
| Cipher | Blowfish, ECB mode, PKCS5 padding |
|
|
||||||
| Key for `sfunc=r` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` |
|
|
||||||
| Key for everything else | `derive_session_key(smod)` |
|
|
||||||
| Request encoding | JSON → encrypt → base64 → URL-encode → form field `data=` |
|
|
||||||
| Response encoding | base64 → decrypt → JSON |
|
|
||||||
| Key input | raw UTF-8 bytes of key string |
|
|
||||||
| Plaintext input | raw UTF-8 bytes of JSON string |
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
# MIB Faisanet — Login Flow
|
|
||||||
|
|
||||||
Fully reverse engineered from a captured HAR trace of a first-time device
|
|
||||||
registration followed immediately by a regular login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Corrections
|
|
||||||
|
|
||||||
The DH parameter names in the app's source are **misleading**:
|
|
||||||
|
|
||||||
| App variable | DH role | Value |
|
|
||||||
|---|---|---|
|
|
||||||
| `A_VALUE` | Exponent / client private key | `15635168026...` (shorter) |
|
|
||||||
| `P_VALUE` | Prime modulus | `24103124269...` (longer) |
|
|
||||||
| `G_VALUE` | Generator | `2` |
|
|
||||||
|
|
||||||
The session key derivation is:
|
|
||||||
```python
|
|
||||||
shared = pow(smod, A_VALUE, P_VALUE) # NOT pow(smod, P_VALUE, A_VALUE)
|
|
||||||
sha256_hex = SHA256(str(shared)).upper()
|
|
||||||
session_key = base64(bytes.fromhex(sha256_hex))
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The full login sequence consists of two phases:
|
|
||||||
|
|
||||||
| Phase | Purpose | Key used |
|
|
||||||
|---|---|---|
|
|
||||||
| **Phase 1** — Device registration | First time this device+account pair is seen | DH session key from `sfunc=r` |
|
|
||||||
| **Phase 2** — Regular login | Every subsequent login | key1/key2 (from phase 1) → second DH → new session key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Full Flow Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
Client Server
|
|
||||||
| |
|
|
||||||
| [0] sfunc=r (DEFAULT_KEY) |
|
|
||||||
| { cmod, appId, routePath:S40, ... } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { smod, nonceGenerator, xxid, ... } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| derive session_key_1 = DH(smod) |
|
|
||||||
| |
|
|
||||||
| [1] sfunc=n routePath:A44 (session_key_1)|
|
|
||||||
| { uname } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { loginType, userSalt } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| |
|
|
||||||
| [2] sfunc=n routePath:C41 (session_key_1)| ← device registration init
|
|
||||||
| { uname, pgf03, clientSalt } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { key1, key2, otpTypes, fullName, ... } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| |
|
|
||||||
| [3] sfunc=n routePath:C42 (session_key_1)| ← OTP verify (registration)
|
|
||||||
| { otp, uname, otpType } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { key1, key2, encryptionMethod:2, ... } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| store key1, key2 on device |
|
|
||||||
| |
|
|
||||||
| [4] sfunc=i (key1) | ← second DH key exchange
|
|
||||||
| { cmod, appId, routePath:S40, key2 } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { smod, nonceGenerator, xxid, ... } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| derive session_key_2 = DH(smod) |
|
|
||||||
| |
|
|
||||||
| [5] sfunc=n routePath:A44 (session_key_2)|
|
|
||||||
| { uname } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { loginType, userSalt } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| |
|
|
||||||
| [6] sfunc=n routePath:A41 (session_key_2)| ← regular login init
|
|
||||||
| { uname, pgf03, clientSalt, requireBankData:1 }|
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { primaryOTPType, otpTypes, email, uuid, uuid2, ... }|
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| |
|
|
||||||
| [7] sfunc=n routePath:P41 (session_key_2)| ← fetch profile image
|
|
||||||
| { imageHash } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { profileImage (base64 JPEG) } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
| |
|
|
||||||
| [8] sfunc=n routePath:A42 (session_key_2)| ← OTP verify (regular login)
|
|
||||||
| { otp, uname, otpType } |
|
|
||||||
|--------------------------------------------->|
|
|
||||||
| { ... session established ... } |
|
|
||||||
|<---------------------------------------------|
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step-by-Step Reference
|
|
||||||
|
|
||||||
### [0] Initial Key Exchange — `sfunc=r`
|
|
||||||
|
|
||||||
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
|
|
||||||
|
|
||||||
**Request body** (inner `data` field):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cmod": "<G^A_VALUE mod P_VALUE as decimal string>",
|
|
||||||
"appId": "IOS17.2-<15 random chars>",
|
|
||||||
"routePath": "S40",
|
|
||||||
"sodium": "<random 20-bit int>",
|
|
||||||
"xxid": "<random 40-bit int>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Full request** (outer wrapper, encrypted together):
|
|
||||||
```json
|
|
||||||
{ "sfunc": "r", "data": { ...above... } }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** (decrypted with DEFAULT_KEY):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "201",
|
|
||||||
"reasonText": "Key generated successfully.",
|
|
||||||
"smod": "<server DH public key>",
|
|
||||||
"nonceGenerator": "<instruction string>",
|
|
||||||
"xxid": "<session token — carry for all subsequent calls>",
|
|
||||||
"sodium": "<server random>",
|
|
||||||
"encMethod": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After this step:
|
|
||||||
- Derive `session_key_1 = derive_session_key(smod)`
|
|
||||||
- Save `xxid` and `nonceGenerator`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
|
|
||||||
|
|
||||||
**Key**: `session_key_1`
|
|
||||||
|
|
||||||
**Request** (encrypted):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"uname": "<username>",
|
|
||||||
"nonce": "<computed from nonceGenerator>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random 20-bit int>",
|
|
||||||
"routePath": "A44",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "108",
|
|
||||||
"reasonText": "Auth type retrieved!",
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"loginType": "1",
|
|
||||||
"userSalt": "<server salt for password hashing>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [2] Device Registration Init — `sfunc=n`, `routePath: C41`
|
|
||||||
|
|
||||||
First-time only. Registers this device+account pair.
|
|
||||||
|
|
||||||
**Key**: `session_key_1`
|
|
||||||
|
|
||||||
**Request**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"uname": "<username>",
|
|
||||||
"pgf03": "<salted password hash — see below>",
|
|
||||||
"clientSalt": "<random 32-char string>",
|
|
||||||
"nonce": "<nonce>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random>",
|
|
||||||
"routePath": "C41",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "201",
|
|
||||||
"reasonText": "Registration Initialization Successfully.",
|
|
||||||
"primaryOTPType": "3",
|
|
||||||
"roleName": "Consumer Premium",
|
|
||||||
"otpTypes": [2, 3],
|
|
||||||
"fullName": "<user's full name>",
|
|
||||||
"lastLoginTime": "<datetime>",
|
|
||||||
"customerImgHash": "<hash>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [3] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
|
|
||||||
|
|
||||||
**Key**: `session_key_1`
|
|
||||||
|
|
||||||
**Request**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"otp": "<6-digit OTP>",
|
|
||||||
"uname": "<username>",
|
|
||||||
"otpType": "3",
|
|
||||||
"nonce": "<nonce>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random>",
|
|
||||||
"routePath": "C42",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "101",
|
|
||||||
"reasonText": "registration success",
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"appId": "<appId>",
|
|
||||||
"createdDate": "<datetime>",
|
|
||||||
"key1": "<device credential 1 — store securely>",
|
|
||||||
"key2": "<device credential 2 — store securely>",
|
|
||||||
"encryptionMethod": "2",
|
|
||||||
"appAgent": "android/1.0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`key1` and `key2` are long-lived device credentials. `key1` is the Blowfish key
|
|
||||||
for the next `sfunc=i` call. `key2` is sent as plaintext in the outer wrapper of
|
|
||||||
that call.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [4] Authenticated Key Exchange — `sfunc=i`
|
|
||||||
|
|
||||||
Second DH exchange, authenticated with the device credentials.
|
|
||||||
|
|
||||||
**Key**: `key1`
|
|
||||||
|
|
||||||
**Request** (outer wrapper includes `key2`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "i",
|
|
||||||
"key2": "<key2 from registration>",
|
|
||||||
"data": {
|
|
||||||
"cmod": "<G^A_VALUE mod P_VALUE>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"routePath": "S40",
|
|
||||||
"sodium": "<random 20-bit int>",
|
|
||||||
"xxid": "<random 40-bit int>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** (decrypted with `key1`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "201",
|
|
||||||
"reasonText": "Key generated successfully.",
|
|
||||||
"smod": "<new server DH public key>",
|
|
||||||
"nonceGenerator": "<new instruction string>",
|
|
||||||
"xxid": "<new session token>",
|
|
||||||
"encMethod": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After this step:
|
|
||||||
- Derive `session_key_2 = derive_session_key(smod)`
|
|
||||||
- Replace `xxid` and `nonceGenerator` with new values
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [5] Get Auth Type — `sfunc=n`, `routePath: A44`
|
|
||||||
|
|
||||||
Same as step [1] but with `session_key_2`. Fetches `userSalt` for password hashing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [6] Regular Login Init — `sfunc=n`, `routePath: A41`
|
|
||||||
|
|
||||||
**Key**: `session_key_2`
|
|
||||||
|
|
||||||
**Request**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"uname": "<username>",
|
|
||||||
"pgf03": "<salted password hash>",
|
|
||||||
"clientSalt": "<random 32-char string>",
|
|
||||||
"pmodTime": 0,
|
|
||||||
"requireBankData": 1,
|
|
||||||
"nonce": "<nonce>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random>",
|
|
||||||
"routePath": "A41",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "104",
|
|
||||||
"reasonText": "Initialization Successful",
|
|
||||||
"primaryOTPType": "3",
|
|
||||||
"roleName": "Consumer Premium",
|
|
||||||
"otpTypes": [2, 3],
|
|
||||||
"email": "<masked email>",
|
|
||||||
"uuid": "<uuid1>",
|
|
||||||
"uuid2": "<uuid2>",
|
|
||||||
"xxid": "<xxid>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [7] Get Profile Image — `sfunc=n`, `routePath: P41`
|
|
||||||
|
|
||||||
**Key**: `session_key_2`
|
|
||||||
|
|
||||||
**Request**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"imageHash": "<customerImgHash from step 2/6>",
|
|
||||||
"nonce": "<nonce>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random>",
|
|
||||||
"routePath": "P41",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"reasonCode": "201",
|
|
||||||
"reasonText": "Image Found",
|
|
||||||
"profileImage": "<base64 JPEG>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [8] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
|
|
||||||
|
|
||||||
**Key**: `session_key_2`
|
|
||||||
|
|
||||||
**Request**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sfunc": "n",
|
|
||||||
"xxid": "<session xxid>",
|
|
||||||
"data": {
|
|
||||||
"otp": "<6-digit OTP>",
|
|
||||||
"uname": "<username>",
|
|
||||||
"otpType": "3",
|
|
||||||
"nonce": "<nonce>",
|
|
||||||
"appId": "<appId>",
|
|
||||||
"sodium": "<random>",
|
|
||||||
"routePath": "A42",
|
|
||||||
"xxid": "<session xxid>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Password Hashing (`pgf03`)
|
|
||||||
|
|
||||||
The password is never sent in plaintext. The scheme prevents replay attacks by
|
|
||||||
mixing in a server-provided salt and a client-generated random salt.
|
|
||||||
|
|
||||||
```
|
|
||||||
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
|
|
||||||
```
|
|
||||||
|
|
||||||
All SHA256 values are uppercase hex strings.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
|
||||||
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
|
|
||||||
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
|
|
||||||
h3 = hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
|
|
||||||
return h3
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Route Paths Summary
|
|
||||||
|
|
||||||
| routePath | sfunc | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `S40` | `r` or `i` | DH key exchange |
|
|
||||||
| `A44` | `n` | Get auth type / userSalt |
|
|
||||||
| `A41` | `n` | Regular login initialization |
|
|
||||||
| `A42` | `n` | OTP verification (regular login) |
|
|
||||||
| `C41` | `n` | Device registration initialization |
|
|
||||||
| `C42` | `n` | OTP verification (registration) |
|
|
||||||
| `P41` | `n` | Get profile image |
|
|
||||||
| `P40` | `n` | Update profile image |
|
|
||||||
| `P42` | `n` | Delete profile image |
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# MIB Faisanet API
|
||||||
|
|
||||||
|
Reverse-engineered from `mv.com.mib.faisamobilex` (Faisanet Mobile Banking, React Native / Hermes bytecode v96).
|
||||||
|
|
||||||
|
[Play Store](https://play.google.com/store/apps/details?id=mv.com.mib.faisamobilex)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
MIB uses **two completely separate backends**:
|
||||||
|
|
||||||
|
| Backend | Base URL | Auth | Used for |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Encrypted API | `https://faisanet.mib.com.mv/faisamobilex_smvc/` | Blowfish + DH session key | Login, key exchange |
|
||||||
|
| WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Encrypted API
|
||||||
|
|
||||||
|
All calls to the encrypted API are `POST /` with `Content-Type: application/x-www-form-urlencoded; charset=utf-8` and form body:
|
||||||
|
|
||||||
|
```
|
||||||
|
sfunc=<function_code>&data=<url_encoded_base64_blowfish_ciphertext>
|
||||||
|
```
|
||||||
|
|
||||||
|
The request JSON is encrypted with Blowfish (ECB, PKCS5) before sending. The response body is also base64-encoded Blowfish ciphertext.
|
||||||
|
|
||||||
|
Two keys are used:
|
||||||
|
|
||||||
|
| Phase | Key |
|
||||||
|
|---|---|
|
||||||
|
| `sfunc=r` (initial key exchange) | `DEFAULT_KEY` (hardcoded in app) |
|
||||||
|
| All subsequent requests | DH-derived session key |
|
||||||
|
|
||||||
|
See [01-encryption.md](01-encryption.md) for full details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebView Session Auth
|
||||||
|
|
||||||
|
After login, all data endpoints use cookie-based auth on `faisamobilex-wv.mib.com.mv`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||||
|
```
|
||||||
|
|
||||||
|
These values come from the login flow — `xxid` and `nonceGenerator` from the DH key exchange response.
|
||||||
|
|
||||||
|
### WebView AJAX Headers
|
||||||
|
|
||||||
|
All AJAX `POST` calls also require:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Requested-With: XMLHttpRequest
|
||||||
|
Accept: */*
|
||||||
|
Origin: https://faisamobilex-wv.mib.com.mv
|
||||||
|
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Referer` value varies per endpoint (documented per endpoint).
|
||||||
|
|
||||||
|
### WebView User-Agent
|
||||||
|
|
||||||
|
```
|
||||||
|
Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
| # | File | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | [01-encryption.md](01-encryption.md) | Blowfish encryption, DH key exchange, nonce computation |
|
||||||
|
| 2 | [02-login.md](02-login.md) | Device registration and regular login flows |
|
||||||
|
| 3 | [03-accounts.md](03-accounts.md) | Select profile, account balances |
|
||||||
|
| 4 | [04-history.md](04-history.md) | Transaction history |
|
||||||
|
| 5 | [05-cards.md](05-cards.md) | Debit card list |
|
||||||
|
| 6 | [06-financing.md](06-financing.md) | Financing deals |
|
||||||
|
| 7 | [07-profile.md](07-profile.md) | Personal profile (HTML scrape) |
|
||||||
|
| 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer |
|
||||||
|
| 9 | [09-contacts.md](09-contacts.md) | Beneficiary management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Start here →** [01-encryption.md](01-encryption.md)
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# MIB Account Lookup Routing
|
|
||||||
|
|
||||||
Before initiating a transfer, the recipient input must be resolved to a verified account name and
|
|
||||||
account number. Three different endpoints are used depending on the format of the input.
|
|
||||||
|
|
||||||
## Input Format Routing
|
|
||||||
|
|
||||||
| Input format | Endpoint | Body field |
|
|
||||||
|-------------------------------------------|---------------------------------------|-----------------|
|
|
||||||
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` |
|
|
||||||
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` |
|
|
||||||
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` |
|
|
||||||
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` |
|
|
||||||
| Email address (contains `@`) | `AjaxAlias/getAlias` | `aliasName` |
|
|
||||||
|
|
||||||
All endpoints share the same WebView session auth (see `contacts.md` for cookie format) and use
|
|
||||||
`Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoint Details
|
|
||||||
|
|
||||||
### 1. IPS Account Lookup — Local / BML accounts (13 digits, starts with 7)
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount`
|
|
||||||
|
|
||||||
Body: `benefAccount=7700000000000` (13 digits)
|
|
||||||
|
|
||||||
**Success response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "2",
|
|
||||||
"reasonCode": "201",
|
|
||||||
"reasonText": "Request Successful. Account Found",
|
|
||||||
"accountName": "ACCOUNT HOLDER NAME",
|
|
||||||
"bankBic": "MALBMVMV"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields used:
|
|
||||||
- `accountName` — account holder name
|
|
||||||
- `bankBic` — bank SWIFT/BIC code
|
|
||||||
|
|
||||||
The account number is already known from the input; it is not returned in the response.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. MIB Internal Account Name Lookup — MIB accounts (17 digits, starts with 9)
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName`
|
|
||||||
|
|
||||||
Body: `accountNo=90100000000000000` (17 digits)
|
|
||||||
|
|
||||||
**Success response** (exact structure to be confirmed):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "1",
|
|
||||||
"reasonText": "Account found",
|
|
||||||
"accountName": "ACCOUNT HOLDER NAME"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields used:
|
|
||||||
- `accountName` — account holder name (check at root level or inside `data` object)
|
|
||||||
|
|
||||||
The account number is already known from the input; bank is always MIB (`MADVMVMV`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Favara Alias Lookup — Shortcodes, A-IDs, emails
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
|
|
||||||
|
|
||||||
Body: `aliasName=<alias>`
|
|
||||||
|
|
||||||
Accepted alias formats:
|
|
||||||
- `7` or `9` followed by 6 digits → e.g. `7012345`, `9198026`
|
|
||||||
- `A` followed by 6 digits → e.g. `A123456`
|
|
||||||
- Email address → e.g. `user@example.com`
|
|
||||||
|
|
||||||
**Success response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "2",
|
|
||||||
"reasonCode": "203",
|
|
||||||
"reasonText": " Favara ID found",
|
|
||||||
"data": {
|
|
||||||
"TxId": "BANK00001",
|
|
||||||
"CdtrAcct": {
|
|
||||||
"Acct": "90100000000000000",
|
|
||||||
"FinInstnId": "MADVMVMV"
|
|
||||||
},
|
|
||||||
"BfyNm": "Account Holder Name",
|
|
||||||
"RegDtTm": "2023-01-01T00:00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields used from `data`:
|
|
||||||
- `BfyNm` — beneficiary name (trim whitespace)
|
|
||||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
|
||||||
- `CdtrAcct.FinInstnId` — bank institution ID
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
All three endpoints return `"success": false` on failure with a human-readable `reasonText`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"responseCode": "0",
|
|
||||||
"reasonText": "Account not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Always show `reasonText` directly to the user as the error message.
|
|
||||||
- For non-200 HTTP responses, also attempt to parse `reasonText` from the body before falling back to a generic error.
|
|
||||||
- If the input does not match any known format, reject it client-side before making any request.
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# MIB Contacts (Beneficiary) API
|
|
||||||
|
|
||||||
The contacts/beneficiary system is served from the MIB WebView subdomain. All endpoints use
|
|
||||||
session-cookie authentication (same cookies as the financing WebView).
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
All requests use the same session cookies:
|
|
||||||
|
|
||||||
```
|
|
||||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
|
||||||
```
|
|
||||||
|
|
||||||
All AJAX POST requests also require:
|
|
||||||
|
|
||||||
```
|
|
||||||
X-Requested-With: XMLHttpRequest
|
|
||||||
Origin: https://faisamobilex-wv.mib.com.mv
|
|
||||||
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
|
|
||||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### 1. Get Categories
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories`
|
|
||||||
|
|
||||||
No request body required (empty POST).
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "1",
|
|
||||||
"reasonText": "Category retrieval success",
|
|
||||||
"reasonCode": "105",
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "100001",
|
|
||||||
"categoryName": "Myself",
|
|
||||||
"icon": "f091",
|
|
||||||
"createdDate": "2023-01-01 00:00:00",
|
|
||||||
"modifiedDate": null,
|
|
||||||
"numBenef": "2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "100002",
|
|
||||||
"categoryName": "Friends",
|
|
||||||
"icon": "f095",
|
|
||||||
"createdDate": "2023-01-01 00:00:00",
|
|
||||||
"modifiedDate": "2023-01-02 00:00:00",
|
|
||||||
"numBenef": "10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "100003",
|
|
||||||
"categoryName": "Business",
|
|
||||||
"icon": "f097",
|
|
||||||
"createdDate": "2023-01-01 00:00:00",
|
|
||||||
"modifiedDate": "2023-01-02 00:00:00",
|
|
||||||
"numBenef": "8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "100004",
|
|
||||||
"categoryName": "Family",
|
|
||||||
"icon": "f090",
|
|
||||||
"createdDate": "2023-01-01 00:00:00",
|
|
||||||
"modifiedDate": "2023-01-02 00:00:00",
|
|
||||||
"numBenef": "5"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `id` — category ID (used as `searchCategoryId` when filtering contacts)
|
|
||||||
- `categoryName` — display name
|
|
||||||
- `icon` — font-awesome icon code (used in web UI, ignore in native app)
|
|
||||||
- `numBenef` — number of beneficiaries in this category (string)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Get Contacts (Paginated)
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main`
|
|
||||||
|
|
||||||
**Request body (form-urlencoded):**
|
|
||||||
|
|
||||||
| Field | Example | Description |
|
|
||||||
|--------------------|----------|-----------------------------------------------------------|
|
|
||||||
| `page` | `1` | Page number (1-based) |
|
|
||||||
| `search` | `` | Search query (empty = all) |
|
|
||||||
| `searchCategoryId` | `0` | Category filter (`0` = all categories) |
|
|
||||||
| `benefType` | `A` | Beneficiary type: `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
|
|
||||||
| `sortBenef` | `name` | Sort field |
|
|
||||||
| `sortDir` | `asc` | Sort direction |
|
|
||||||
| `start` | `1` | Record start index (1-based) |
|
|
||||||
| `end` | `100` | Record end index |
|
|
||||||
| `includeCount` | `1` | Include `total_count` in response |
|
|
||||||
|
|
||||||
**Beneficiary types:**
|
|
||||||
- `L` — Local (other Maldivian banks, e.g. BML)
|
|
||||||
- `I` — Internal (MIB to MIB transfers)
|
|
||||||
- `S` — Swift (international transfers)
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "1",
|
|
||||||
"reasonText": "beneficiary retrieval success",
|
|
||||||
"reasonCode": "103",
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"benefNo": "100001",
|
|
||||||
"benefName": "Person Name",
|
|
||||||
"benefNickName": "Nickname",
|
|
||||||
"benefAccount": "7700000000001",
|
|
||||||
"benefType": "L",
|
|
||||||
"bankColor": "#AC0000",
|
|
||||||
"benefBankName": "Bank of Maldives PLC",
|
|
||||||
"bankCode": "BML",
|
|
||||||
"benefBankBranch": null,
|
|
||||||
"benefAddress1": null,
|
|
||||||
"benefAddress2": null,
|
|
||||||
"benefAddress3": null,
|
|
||||||
"benefCity": null,
|
|
||||||
"benefRegion": null,
|
|
||||||
"benefCountry": null,
|
|
||||||
"benefStatus": "A",
|
|
||||||
"benefBankId": "3",
|
|
||||||
"benefSwiftCode": "MALBMVMV",
|
|
||||||
"transferCy": "462",
|
|
||||||
"transferCyDesc": "MVR",
|
|
||||||
"bicCode": null,
|
|
||||||
"intermBankCode": "0",
|
|
||||||
"customerImgHash": "abcd1234hash...",
|
|
||||||
"benefImgHash": "abcd1234hash...",
|
|
||||||
"benefCategoryID": "100002",
|
|
||||||
"BENEF_CIF_NO": null,
|
|
||||||
"rnum": "1",
|
|
||||||
"last": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"benefNo": "100002",
|
|
||||||
"benefName": "Another Person",
|
|
||||||
"benefNickName": "MIB Contact",
|
|
||||||
"benefAccount": "90103100000001000",
|
|
||||||
"benefType": "I",
|
|
||||||
"bankColor": "#FE860E",
|
|
||||||
"benefBankName": "MIB",
|
|
||||||
"bankCode": "MIB",
|
|
||||||
"benefBankBranch": null,
|
|
||||||
"benefStatus": "A",
|
|
||||||
"benefBankId": "2",
|
|
||||||
"benefSwiftCode": "SWIFTCODE",
|
|
||||||
"transferCy": "462",
|
|
||||||
"transferCyDesc": "MVR",
|
|
||||||
"customerImgHash": null,
|
|
||||||
"benefImgHash": null,
|
|
||||||
"benefCategoryID": "0",
|
|
||||||
"rnum": "2",
|
|
||||||
"last": "1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_count": "48",
|
|
||||||
"pos": "1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
- `benefNo` — unique beneficiary ID
|
|
||||||
- `benefNickName` — user-assigned nickname (prefer over `benefName` for display)
|
|
||||||
- `benefType` — `L`, `I`, or `S`
|
|
||||||
- `bankColor` — hex color representing the bank (use for placeholder avatar background)
|
|
||||||
- `customerImgHash` — hash used to fetch profile photo (null if no photo set)
|
|
||||||
- `benefCategoryID` — category ID, `"0"` means uncategorized
|
|
||||||
- `transferCyDesc` — currency (MVR, USD)
|
|
||||||
- `rnum` — row number (1-based position in full sorted list)
|
|
||||||
- `last` — `"1"` if this is the last record on the page
|
|
||||||
|
|
||||||
Pagination: use `start`/`end` to page through results. `total_count` gives the total number of records.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Get Profile Image
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage`
|
|
||||||
|
|
||||||
**Request body (form-urlencoded):**
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------------|------------------------------------|
|
|
||||||
| `imageHash` | The `customerImgHash` from contact |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "1",
|
|
||||||
"reasonCode": "1",
|
|
||||||
"reasonText": "image found",
|
|
||||||
"profileImage": "<base64-encoded JPEG>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `profileImage` — raw base64-encoded JPEG (no data URI prefix)
|
|
||||||
- Decode with `Base64.decode(value, Base64.DEFAULT)` then `BitmapFactory.decodeByteArray(...)`
|
|
||||||
- The same hash may be reused across multiple contacts (deduplication recommended)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Get Stats (optional)
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats`
|
|
||||||
|
|
||||||
No request body required.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "1",
|
|
||||||
"reasonText": "Beneficiary Stats Retrieved",
|
|
||||||
"reasonCode": "109",
|
|
||||||
"data": [
|
|
||||||
{ "type": "L", "count": "30" },
|
|
||||||
{ "type": "I", "count": "10" },
|
|
||||||
{ "type": "S", "count": "2" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Gives counts per beneficiary type. Useful for showing tab badges.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Profile images are fetched on-demand per contact. Cache decoded bitmaps in memory to avoid re-fetching.
|
|
||||||
- Contacts with `customerImgHash == null` have no profile photo; show initials + bank color as placeholder.
|
|
||||||
- The `benefCategoryID` of `"0"` means uncategorized (not in any category group).
|
|
||||||
- Pagination: use `start=1&end=100` for the first 100 records. Increment accordingly using `total_count`.
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# MIB Financing API
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Financing data is fetched from the MIB **WebView** host (`faisamobilex-wv.mib.com.mv`), which is separate from the API host (`faisanet.mib.com.mv`). The response is an HTML page; financing deal data is embedded in `data-*` attributes on card elements.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
Session cookies from the login flow must be sent with the request:
|
|
||||||
|
|
||||||
| Cookie | Value |
|
|
||||||
|----------------|---------------------------------------------|
|
|
||||||
| `mbmodel` | `IOS-1.0` (literal string) |
|
|
||||||
| `xxid` | Session ID from login (`MibSession.xxid`) |
|
|
||||||
| `IBSID` | Same as `xxid` |
|
|
||||||
| `mbnonce` | `nonceGenerator` string from login response |
|
|
||||||
| `time-tracker` | `597` (literal string) |
|
|
||||||
|
|
||||||
### Request Headers
|
|
||||||
|
|
||||||
| Header | Value |
|
|
||||||
|--------------------|------------------------------------|
|
|
||||||
| `User-Agent` | Standard Android WebView UA string |
|
|
||||||
| `X-Requested-With` | `mv.com.mib.faisamobilex` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Response
|
|
||||||
|
|
||||||
**Content-Type:** `text/html; charset=UTF-8`
|
|
||||||
|
|
||||||
The response is a full HTML page. Each financing deal is represented as a `<div>` with the class `finance-card-holder` and all deal fields embedded as `data-*` attributes:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="card border finance-card-holder"
|
|
||||||
data-productDesc = "Product Name"
|
|
||||||
data-dealStatus = "P"
|
|
||||||
data-statusDesc = "Approved"
|
|
||||||
data-dealAmount = "10000.00"
|
|
||||||
data-dealNo = "12345"
|
|
||||||
data-paidAmount = "2500.00"
|
|
||||||
data-outstandingAmount = "7500.00"
|
|
||||||
data-dealDate = "2024-01-15 00:00:00"
|
|
||||||
data-overdueAmount = "0"
|
|
||||||
data-installmentAmount = "500.00"
|
|
||||||
data-noOfInstallments = "24"
|
|
||||||
data-lastPaidDate = "2026-05-01 00:00:00"
|
|
||||||
data-lastPayAmount = "500.00"
|
|
||||||
data-financeCurrency = "462"
|
|
||||||
data-curCodeDesc = "MVR">
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Fields
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-----------------------|---------|------------------------------------------------------|
|
|
||||||
| `productDesc` | String | Product name (e.g. "Ujalaa CG Finance") |
|
|
||||||
| `dealStatus` | String | Status code: `P` = Active/Pending |
|
|
||||||
| `statusDesc` | String | Human-readable status (e.g. "Approved") |
|
|
||||||
| `dealAmount` | Decimal | Total financing amount |
|
|
||||||
| `dealNo` | Integer | Unique deal/contract number |
|
|
||||||
| `paidAmount` | Decimal | Amount paid to date |
|
|
||||||
| `outstandingAmount` | Decimal | Remaining unpaid balance |
|
|
||||||
| `dealDate` | String | Contract start date (`yyyy-MM-dd HH:mm:ss`) |
|
|
||||||
| `overdueAmount` | Decimal | Amount currently overdue (0 if none) |
|
|
||||||
| `installmentAmount` | Decimal | Monthly installment amount |
|
|
||||||
| `noOfInstallments` | Integer | Total number of installments |
|
|
||||||
| `lastPaidDate` | String | Date of most recent payment (`yyyy-MM-dd HH:mm:ss`) |
|
|
||||||
| `lastPayAmount` | Decimal | Amount of most recent payment |
|
|
||||||
| `financeCurrency` | Integer | Currency code (462 = MVR) |
|
|
||||||
| `curCodeDesc` | String | Currency abbreviation (e.g. "MVR") |
|
|
||||||
|
|
||||||
### Parsing Strategy
|
|
||||||
|
|
||||||
Use a regex to find all elements with class `finance-card-holder`, then extract all `data-*` attribute key/value pairs from each match:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
val cardPattern = Regex("""finance-card-holder[^>]+>""")
|
|
||||||
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Completion Date Estimation
|
|
||||||
|
|
||||||
Remaining installments can be estimated from outstanding and installment amounts:
|
|
||||||
|
|
||||||
```
|
|
||||||
remainingInstallments = ceil(outstandingAmount / installmentAmount)
|
|
||||||
completionDate = today + remainingInstallments months
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The WebView endpoint uses a different subdomain (`faisamobilex-wv`) from the encrypted API (`faisanet`).
|
|
||||||
- No encryption is used; the session is maintained purely via cookies.
|
|
||||||
- The HTML is served gzip/brotli compressed; OkHttp handles decompression automatically.
|
|
||||||
- The `time-tracker` cookie value appears to be static at `597` — its purpose is unclear, but omitting it may affect behavior.
|
|
||||||
- Known product names include consumer goods finance and cash financing variants.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# MIB Transfer API
|
|
||||||
|
|
||||||
Transfer endpoints are served from the MIB WebView subdomain, using the same session-cookie auth as
|
|
||||||
financing and contacts.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
```
|
|
||||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
|
||||||
```
|
|
||||||
|
|
||||||
All AJAX POST requests also require:
|
|
||||||
|
|
||||||
```
|
|
||||||
X-Requested-With: XMLHttpRequest
|
|
||||||
Origin: https://faisamobilex-wv.mib.com.mv
|
|
||||||
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
|
|
||||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### 1. Look Up Recipient by Favara Alias
|
|
||||||
|
|
||||||
Resolves a Favara ID (alias) to the account holder name and account number before initiating a transfer.
|
|
||||||
|
|
||||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
|
|
||||||
|
|
||||||
**Request body (form-urlencoded):**
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------------|------------------------------------------|
|
|
||||||
| `aliasName` | The recipient's Favara ID / alias number |
|
|
||||||
|
|
||||||
**Success response (`responseCode: "2"`):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"responseCode": "2",
|
|
||||||
"reasonCode": "203",
|
|
||||||
"reasonText": " Favara ID found",
|
|
||||||
"data": {
|
|
||||||
"TxId": "BANK00001",
|
|
||||||
"CreDtTm": "...",
|
|
||||||
"Resp": {
|
|
||||||
"Rslt": true,
|
|
||||||
"RsltDtls": null
|
|
||||||
},
|
|
||||||
"CdtrAcct": {
|
|
||||||
"Acct": "90100000000000000",
|
|
||||||
"FinInstnId": "MADVMVMV"
|
|
||||||
},
|
|
||||||
"BfyNm": "Account Holder Name",
|
|
||||||
"RegDtTm": "2023-01-01T00:00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Not found / error response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"responseCode": "0",
|
|
||||||
"reasonCode": "400",
|
|
||||||
"reasonText": "Alias not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key fields from `data`:
|
|
||||||
- `BfyNm` — beneficiary full name (trim whitespace)
|
|
||||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
|
||||||
- `CdtrAcct.FinInstnId` — bank institution ID (e.g. `MADVMVMV`, `MALBMVMV`)
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- Use `success` (not `responseCode`) to determine if the lookup succeeded.
|
|
||||||
- Show `BfyNm` + `CdtrAcct.Acct` to the user as confirmation before proceeding.
|
|
||||||
- The `reasonText` from error responses should be shown directly to the user.
|
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Reference in New Issue
Block a user