27 Commits

Author SHA1 Message Date
shihaam da85a31bc6 release version 1.0.9
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m53s
2026-05-28 02:18:38 +05:00
shihaam d292e73fd9 added support for custom per-profile image for BML and Fahipay, MIB works pending
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-28 02:18:01 +05:00
shihaam 3d632606a0 quality of life features: logo and account type shown in trasfer page and contact picker and my accounts in contact picker, also money amount is dispayed bigger
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 01:14:20 +05:00
shihaam 6daeb5f72e Bug fix: contacts page infinite loading without internet
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 00:19:22 +05:00
shihaam c4d3c1efd4 better network error handling, fix crash when no network in transaction history page
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-28 00:14:11 +05:00
shihaam 0560c53ae3 Show no accounts found text when there are no accounts in cache
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 23:42:05 +05:00
shihaam a37454de00 improve clearing cache and logout (it was showing logged-out account info on dashboard
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 23:37:34 +05:00
shihaam daf9b0475a add zoom QR and flashlight button
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-27 23:07:01 +05:00
shihaam c4ad35e6b9 Fix bug: transfer source drop down automatically closing to update profile image
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 22:40:05 +05:00
shihaam 3e8ea90701 handle server timeouts instead of crashing
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 22:14:31 +05:00
shihaam ef919aa179 show bank/profile image in accounts and drop down
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 22:00:47 +05:00
shihaam c98a3e3e89 show card network in source account drop down
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 21:35:27 +05:00
shihaam 0654c711d6 bug fix: nav bar buttons disappearing after some updates
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-27 21:28:19 +05:00
shihaam b67368c94a unified pay with QR and tranfer confirm dialog box
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 21:22:04 +05:00
shihaam a6e7e61b58 added support for QR payments from BML gateway
Auto Tag on Version Change / check-version (push) Failing after 13s
2026-05-27 21:08:01 +05:00
shihaam e974a95708 added support for static QR payments from BML cards
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 20:32:17 +05:00
shihaam de11fbe0d3 skill issue on mib
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-27 19:00:12 +05:00
shihaam 5d8ab76477 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-27 18:35:42 +05:00
shihaam d637877167 update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 18:04:39 +05:00
shihaam ea227bf3b9 impprove ci performance
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-24 00:29:40 +05:00
shihaam 6b3131069e update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-24 00:27:59 +05:00
shihaam 8037ce3f02 Merge pull request 'add product group mapping for cards' (#4) from fix/bmlapi-card-parser-add-missing-product-groups into main
Auto Tag on Version Change / check-version (push) Successful in 4s
Reviewed-on: #4
2026-05-24 00:04:47 +05:00
flamexode cecf0bedfc add product group mapping for cards 2026-05-23 23:56:15 +05:00
shihaam 256f216da4 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 23:46:00 +05:00
shihaam 0a27de4a34 update bml api docs
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-23 23:33:31 +05:00
shihaam a3f8852163 some android studio bs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-23 23:13:07 +05:00
shihaam 8e345746ed pending finaces on dashboard is now a button that takes you to finaces page 2026-05-23 23:12:50 +05:00
88 changed files with 6494 additions and 2044 deletions
+1 -3
View File
@@ -1,11 +1,9 @@
services:
release:
# image: git.shihaam.dev/dockerfiles/android-builder
image: git.shihaam.dev/dockerfiles/runners/gradle
hostname: isodroid
network_mode: host
env_file: .env
volumes:
- ./release:/release
- ../../:/source
# - /root/.cache/cache-runners/gradle:/root/.gradle
- /root/.cache/cache-runners/gradle:/root/.gradle
+1 -1
View File
@@ -3,7 +3,7 @@
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" />
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
<Target type="DEFAULT_BOOT">
<handle>
+8
View File
@@ -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>
+9 -50
View File
@@ -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.
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
@@ -8,60 +8,14 @@ A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white)
![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg)
## 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
- Android 8.0+ (API 26)
- Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Building
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
## Download
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
## Privacy
@@ -70,3 +24,8 @@ No data ever leaves your device except the API calls to the banking services the
## 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.
## License
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 7
versionName = "1.0.8"
versionCode = 8
versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -4,6 +4,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat
import java.util.Locale
@@ -30,6 +31,7 @@ class BmlHistoryClient {
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
@@ -82,6 +84,7 @@ class BmlHistoryClient {
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
@@ -66,6 +66,22 @@ data class BmlLoanDetail(
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(
val type: String,
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.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction
import java.util.concurrent.TimeUnit
@@ -32,8 +33,10 @@ class FahipayHistoryClient {
.header("User-Agent", UA)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
return try {
val obj = JSONObject(json)
val total = obj.optInt("total", 0)
@@ -5,6 +5,7 @@ import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
class MibHistoryClient {
@@ -60,6 +61,7 @@ class MibHistoryClient {
.build()
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 json = try { JSONObject(bodyStr) } catch (_: Exception) { 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)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
.addNetworkInterceptor { chain ->
val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0")
.header("Accept", "application/json")
@@ -366,6 +366,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
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 {
val request = Request.Builder()
.url(BASE_URL)
@@ -23,6 +23,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
@@ -149,8 +150,15 @@ class AccountHistoryFragment : Fragment() {
pendingIconUrls.clear()
firstPageDone = false
fetcher = HistoryFetcher(account)
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
// Restore cache immediately so data stays visible while refreshing
val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPage()
}
@@ -165,9 +173,20 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
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
if (_binding == null) return@launch
if (!firstPageDone) {
firstPageDone = true
@@ -175,6 +194,13 @@ class AccountHistoryFragment : Fragment() {
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()) {
val existingIds = allTransactions.map { it.id }.toHashSet()
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.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
@@ -17,7 +19,11 @@ import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter(
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>() {
var onTransferClick: ((BankAccount) -> Unit)? = null
@@ -112,6 +118,8 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) {
private var boundHash: String? = null
fun bind(account: BankAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number
@@ -123,6 +131,30 @@ class AccountsAdapter(
copyToClipboard(it.context, display.number)
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
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -8,9 +11,15 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
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.databinding.FragmentAccountsBinding
import sh.sar.basedbank.util.ProfileImageStore
class AccountsFragment : Fragment() {
@@ -18,6 +27,7 @@ class AccountsFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountsAdapter
private val profileImageCache = mutableMapOf<String, Bitmap>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
@@ -25,9 +35,49 @@ class AccountsFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
}
val app = requireActivity().application as BasedBankApp
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 ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
}
@@ -43,7 +93,10 @@ class AccountsFragment : Fragment() {
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) }
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 isManualEntry: Boolean = false,
val imageHash: String? = null,
val inactiveReason: String? = null
val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
) : PickerItem()
}
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
binding.tvPrimary.text = item.displayName
binding.tvSecondary.text = item.subtitle
val cached = item.imageHash?.let { imageCache[it] }
if (cached != null) {
binding.ivIcon.setImageBitmap(cached)
if (item.balance != null) {
binding.tvBalance.text = item.balance
binding.tvBalance.visibility = android.view.View.VISIBLE
} else {
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.tvBalance.visibility = android.view.View.GONE
}
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
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
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.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
@@ -147,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.loadAllContacts()
(activity as? HomeActivity)?.triggerRefresh()
}
private fun attachMediator(pages: List<TabDef>) {
@@ -225,17 +229,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
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(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} $accBal",
subtitle = acc.accountNumber,
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash,
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null
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)
val isSame = acc.accountNumber == fromAccountNumber
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(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} $cardBal",
subtitle = acc.accountNumber,
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash,
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc
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) {
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
lifecycleScope.launch(Dispatchers.IO) {
try {
@@ -318,6 +353,16 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
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() {
super.onDestroyView()
_binding = null
@@ -48,7 +48,6 @@ class ContactsFragment : Fragment() {
private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter
private var contactsRefreshing = false
private data class TabPage(val categoryId: String?, val label: String)
@@ -136,8 +135,8 @@ class ContactsFragment : Fragment() {
(activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.setOnRefreshListener {
contactsRefreshing = true
(activity as? HomeActivity)?.loadAllContacts()
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
@@ -149,10 +148,6 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else 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
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -8,6 +10,7 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -32,6 +35,22 @@ class DashboardFragment : Fragment() {
private val binding get() = _binding!!
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 {
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
return binding.root
@@ -53,6 +72,10 @@ class DashboardFragment : Fragment() {
binding.swipeRefresh.isRefreshing = false
}
binding.cardPendingFinances.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
val cardAdapter = DashboardCardAdapter()
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = cardAdapter
@@ -277,18 +300,21 @@ class DashboardFragment : Fragment() {
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
}
}
val isMib = item is CardItem.Mib
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 nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
if (nfcSupported) {
btnPayNfc.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
} else {
btnPayNfc.setOnClickListener(null)
btnPayNfc.setOnClickListener {
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
@@ -21,7 +21,6 @@ class FinancingFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: FinancingAdapter
private var financingRefreshing = false
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
@@ -46,8 +45,8 @@ class FinancingFragment : Fragment() {
}
binding.swipeRefresh.setOnRefreshListener {
financingRefreshing = true
(activity as? HomeActivity)?.triggerRefreshFinancing()
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
@@ -74,10 +73,6 @@ class FinancingFragment : Fragment() {
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
if (financingRefreshing) {
financingRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
}
override fun onResume() {
@@ -549,12 +549,12 @@ fun applyNavLabelVisibility() {
autoRefresh(store)
}
private fun showConnectivityBanner(message: String) {
fun showConnectivityBanner(message: String) {
binding.connectivityBanner.text = message
binding.connectivityBanner.visibility = View.VISIBLE
}
private fun hideConnectivityBanner() {
fun hideConnectivityBanner() {
binding.connectivityBanner.visibility = View.GONE
}
@@ -9,6 +9,7 @@ object NavCustomization {
data class NavItemDef(
val id: Int,
val key: String,
@DrawableRes val iconRes: Int,
@StringRes val titleRes: 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. */
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_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_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_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_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_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_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
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, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
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, "nav_activities", R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
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, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
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, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
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, "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(
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
)
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putInt("bottom_nav_slot_1", slots[0])
.putInt("bottom_nav_slot_2", slots[1])
.putInt("bottom_nav_slot_3", slots[2])
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
.apply()
}
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("quick_action_1", R.id.nav_transfer),
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
)
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
prefs.edit()
.putInt("quick_action_1", ids[0])
.putInt("quick_action_2", ids[1])
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
.apply()
}
@@ -32,11 +32,15 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser
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.FileOutputStream
@@ -49,10 +53,19 @@ class PayMvQrFragment : Fragment() {
private var selectedAccount: BankAccount? = null
private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
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
// 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)
if (qr == null || qr.accountNumber == null) {
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 ""
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.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
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
}
@@ -1,6 +1,8 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
@@ -10,6 +12,7 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
@@ -29,6 +32,22 @@ class PayWithCardFragment : Fragment() {
private val binding get() = _binding!!
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 {
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
return binding.root
@@ -131,18 +150,21 @@ class PayWithCardFragment : Fragment() {
bindCardStatus(tvCardStatus, bmlStatus)
}
}
val isMib = item is CardItem.Mib
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 nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
if (nfcSupported) {
btnPayNfc.setOnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
} else {
btnPayNfc.setOnClickListener(null)
btnPayNfc.setOnClickListener {
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
}
@@ -13,6 +13,12 @@ import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
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.core.view.ViewCompat
import androidx.core.view.WindowCompat
@@ -52,6 +58,8 @@ class QrScannerActivity : AppCompatActivity() {
textMode = ZxingCpp.TextMode.PLAIN
)
private var camera: Camera? = null
private var torchEnabled = false
private var cameraStarted = false
private val permissionLauncher = registerForActivityResult(
@@ -104,8 +112,36 @@ class QrScannerActivity : AppCompatActivity() {
}
insets
}
binding.btnCancel.setOnClickListener { finish() }
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)
== PackageManager.PERMISSION_GRANTED
@@ -179,9 +215,12 @@ class QrScannerActivity : AppCompatActivity() {
try {
provider.unbindAll()
provider.bindToLifecycle(
camera = provider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
)
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
binding.zoomSlider.value = state.linearZoom
}
} catch (_: Exception) {
finish()
}
@@ -2,7 +2,11 @@ package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -10,6 +14,8 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
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.ui.login.LoginActivity
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ProfileImageStore
import sh.sar.basedbank.util.RecentsCache
import androidx.lifecycle.lifecycleScope
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.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import java.io.ByteArrayOutputStream
import java.io.File
class SettingsLoginsFragment : Fragment() {
@@ -44,6 +54,248 @@ class SettingsLoginsFragment : Fragment() {
private val binding get() = _binding!!
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 {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
return binding.root
@@ -100,18 +352,7 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
showLoginDetails(
title = getString(R.string.fahipay_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${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) } }
)
showFahipayLoginDetails(store, loginId, profile)
}
}
}
@@ -218,8 +459,21 @@ class SettingsLoginsFragment : Fragment() {
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(pencil)
row.addView(toggle)
container.addView(row)
p to toggle
@@ -327,6 +581,10 @@ class SettingsLoginsFragment : Fragment() {
}
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 {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
@@ -349,8 +607,24 @@ class SettingsLoginsFragment : Fragment() {
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(pencil)
row.addView(toggle)
container.addView(row)
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) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
@@ -642,6 +985,7 @@ class SettingsLoginsFragment : Fragment() {
app.mibLoginFlows.remove(loginId)
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
viewModel.financing.value = emptyList()
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
@@ -652,12 +996,17 @@ class SettingsLoginsFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
// Remove all per-profile sessions for this login from the in-memory map
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
profiles.forEach { app.bmlSessions.remove(it.profileId) }
profiles.forEach { p ->
app.bmlSessions.remove(p.profileId)
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
}
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
store.clearBmlCredentials(loginId)
app.bmlProfilesMap.remove(loginId)
app.bmlLoginFlows.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
viewModel.bmlLimits.value = emptyList()
viewModel.bmlLoanDetails.value = emptyMap()
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
@@ -665,6 +1014,7 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutFahipay(store: CredentialStore, loginId: String) {
val ctx = requireContext()
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
store.clearFahipayCredentials(loginId)
val app = requireActivity().application as BasedBankApp
app.fahipaySessions.remove(loginId)
@@ -677,6 +1027,7 @@ class SettingsLoginsFragment : Fragment() {
private fun clearAllCaches(ctx: Context) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.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.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.FinancingCache
@@ -21,6 +23,7 @@ class SettingsStorageFragment : Fragment() {
private var _binding: FragmentSettingsStorageBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
@@ -32,6 +35,7 @@ class SettingsStorageFragment : Fragment() {
val ctx = requireContext()
clearAllCaches(ctx)
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) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.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.api.bml.BmlAccountClient
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.BmlTransferResult
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.AccountInputParser
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.RecentsCache
import sh.sar.basedbank.util.ReceiptStore
@@ -82,11 +86,17 @@ class TransferFragment : Fragment() {
private var resolvedAccountNumber = ""
private var resolvedRecipientName = ""
private var resolvedBankName = ""
private var resolvedToOwnAccount: BankAccount? = null
// Selected Fahipay service when source is Fahipay and destination is a phone number
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
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
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
private var bmlOtpState = BmlOtpState.NONE
@@ -113,6 +123,14 @@ class TransferFragment : Fragment() {
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
// 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)
if (qr == null || qr.accountNumber == null) {
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_AMOUNT_PREFILL = "amount_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 {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
@@ -182,6 +208,7 @@ class TransferFragment : Fragment() {
viewModel.hideAmounts.observe(viewLifecycleOwner) {
accountDropdownAdapter?.notifyDataSetChanged()
selectedAccount?.let { showFromCard(it) }
resolvedToOwnAccount?.let { showToCard(it) }
}
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_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() {
@@ -293,27 +372,102 @@ class TransferFragment : Fragment() {
val bankLabel = when (account.bank) {
"BML" -> "BML"
"FAHIPAY" -> "FP"
"MIB" -> "MIB"
else -> null
}
val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card"
account.profileType == "BML_CREDIT" -> "Credit Card"
account.profileType == "BML_DEBIT" -> "Debit Card"
account.accountTypeName.isNotBlank() -> account.accountTypeName
else -> account.profileType
}
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 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.tvFromAccountNumber.text = account.accountNumber
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, if (hide) maskAmount(balancePart) else balancePart).joinToString(" · ")
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
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.cardFromInfo.visibility = View.VISIBLE
}
if (account.bank != "BML" && account.profileImageHash != null) {
loadFromPhoto(account.profileImageHash)
private fun showToCard(account: BankAccount) {
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 bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
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) { }
}
@@ -342,6 +499,7 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener {
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedToOwnAccount = null
selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE
binding.layoutServiceSelector.visibility = View.INVISIBLE
@@ -357,6 +515,7 @@ class TransferFragment : Fragment() {
if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
@@ -466,9 +625,16 @@ class TransferFragment : Fragment() {
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
if (matchedAcc != null) {
showToCard(matchedAcc)
} 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.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
@@ -593,9 +759,18 @@ class TransferFragment : Fragment() {
val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = subtitle
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == accountNumber }
if (ownAccount != null) {
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.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
@@ -630,7 +805,76 @@ class TransferFragment : Fragment() {
// ── 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() {
// 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 {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
@@ -710,36 +954,31 @@ class TransferFragment : Fragment() {
activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} 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())
.setTitle(R.string.transfer)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null)
if (isUsdToMvr || isSrcCredit) {
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val container = LinearLayout(ctx).apply {
LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
}
container.addView(TextView(ctx).apply { text = mainMsg })
if (isUsdToMvr) {
container.addView(TextView(ctx).apply {
addView(TextView(ctx).apply { text = mainMsg })
if (isUsdToMvr) 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!"
setTextColor(Color.RED)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
if (isSrcCredit) {
container.addView(TextView(ctx).apply {
if (isSrcCredit) 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."
setTextColor(Color.RED)
textSize = 16f
@@ -747,45 +986,123 @@ class TransferFragment : Fragment() {
setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
dialogBuilder.setView(container)
} else {
dialogBuilder.setMessage(mainMsg)
} else null
showConfirmWithBiometric(
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)
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()
doTransfer()
}
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("$currency $amountStr$destDisplay")
.setNegativeButtonText(getString(android.R.string.cancel))
.build()
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
if (bmlGatewayQr) {
val preOk = BmlQrPayClient().preInitiatePayment(
session, debitAccount, info.requestId, amount, info.currency)
if (!preOk) return@withContext null
}
val initiated = BmlQrPayClient().initiatePayment(
session, debitAccount, info.requestId, amount, info.currency)
if (!initiated) return@withContext null
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)
?.otpSeed?.let { Totp.generate(it) } ?: otp
BmlQrPayClient().confirmPayment(
session, debitAccount, info.requestId, amount, info.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.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
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(
src: BankAccount,
destAccount: String,
@@ -851,7 +1168,7 @@ class TransferFragment : Fragment() {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} 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
val initiated = try {
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)
@@ -936,7 +1253,7 @@ class TransferFragment : Fragment() {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} 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?)
}
} 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)
@@ -1192,7 +1509,11 @@ class TransferFragment : Fragment() {
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else {
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() {
if (bmlOtpState != BmlOtpState.NONE) return
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 }
val errors = viewModel.connectivityErrors.value ?: emptySet()
val bankOffline = "NO_INTERNET" in errors ||
@@ -1252,6 +1574,7 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedBankName = ""
resolvedToOwnAccount = null
selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE
binding.chipGroupService.visibility = View.GONE
@@ -1278,7 +1601,10 @@ class TransferFragment : Fragment() {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
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) { }
}
@@ -1406,10 +1732,104 @@ class TransferFragment : Fragment() {
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
val hide = viewModel.hideAmounts.value ?: false
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
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.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
}
}
@@ -30,6 +30,7 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
@@ -161,8 +162,15 @@ class TransferHistoryFragment : Fragment() {
val accounts = accountStates.map { it.account }
accountStates.clear()
accounts.forEach { accountStates.add(AccountState(it)) }
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
// Restore cache immediately so data stays visible while refreshing
val cached = TransactionCache.load(requireContext(), "transfer")
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPages()
}
@@ -178,6 +186,14 @@ class TransferHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
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 results = mutableListOf<BankTransaction>()
@@ -215,7 +231,7 @@ class TransferHistoryFragment : Fragment() {
list
}
}
} catch (_: Exception) { emptyList<BankTransaction>() }
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
}
}.awaitAll().flatten())
@@ -233,7 +249,7 @@ class TransferHistoryFragment : Fragment() {
if (total > 0) state.fahipayTotal = total
state.fahipayNextStart += list.size
results.addAll(list)
} catch (_: Exception) {}
} catch (e: Exception) { trackError(e) }
}
// 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
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
app.mibMutex.withLock {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (_: Exception) {}
}
try {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (e: Exception) { trackError(e) }
}
} catch (e: Exception) { trackError(e) }
}
}
}
@@ -267,6 +285,16 @@ class TransferHistoryFragment : Fragment() {
}
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) {
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
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
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.
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
@@ -12,35 +22,34 @@ object BmlCardParser {
productCodeToAsset(account.productCode)
fun productCodeToAsset(productCode: String): String = when (productCode) {
"C8201" -> "cards/bml/master_prepaid.png"
"C8205" -> "cards/bml/master_prepaid_travel.png"
"C8005" -> "cards/bml/master_prepaid_travel.png"
"C3007" -> "cards/bml/amex_debit_green.png"
"C1007" -> "cards/bml/visa_debit.png"
"C1003" -> "cards/bml/visa_gold.png"
"C8022" -> "cards/bml/master_gold.png"
"C1020" -> "cards/bml/visa_debit_platinum.png"
"C8902" -> "cards/bml/master_islamic.png"
"C8201", "C8001", "C8009" -> "cards/bml/master_prepaid.png"
"C8205", "C8005", "C8008" -> "cards/bml/master_prepaid_travel.png"
"C3007", "C3017", "C3097", "C3095", "C3077", "C3177" -> "cards/bml/amex_debit_green.png"
"C3003", "C3013", "C3053", "C3023", "C3033", "C3052" -> "cards/bml/amex_debit_gold.png"
"C3009", "C3019", "C3029", "C3099", "C3088", "C3188" -> "cards/bml/amex_credit_gold.png"
"C3001", "C3011", "C3050", "C3051", "C3031" -> "cards/bml/amex_credit_green.png"
"C3005", "C3015", "C3055", "C3054" -> "cards/bml/amex_platinum.png"
"C1003", "C1013", "C1083", "C1084", "C1103", "C1113", "C1183", "C1184" -> "cards/bml/visa_gold.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"
// "?????" -> "cards/bml/amex_credit_gold.png"
// "?????" -> "cards/bml/amex_credit_green.png"
// "?????" -> "cards/bml/amex_debit_gold.png"
// "?????" -> "cards/bml/amex_platinum.png"
// "?????" -> "cards/bml/master.png"
// "?????" -> "cards/bml/master_business_debit.png"
// "?????" -> "cards/bml/master_odiveriyaa.png"
// "?????" -> "cards/bml/master_passport.png"
// "?????" -> "cards/bml/master_platinum.png"
// "?????" -> "cards/bml/master_prepaid_business.png"
// "?????" -> "cards/bml/master_world.png"
// "?????" -> "cards/bml/visa_corporate.png"
// "?????" -> "cards/bml/visa_credit.png"
// "?????" -> "cards/bml/visa_debit_generic.png"
// "?????" -> "cards/bml/visa_debit_islamic.png"
// "?????" -> "cards/bml/visa_infinite.png"
// "?????" -> "cards/bml/visa_platinum.png"
// "?????" -> "cards/bml/visa_student_black.png"
// "?????" -> "cards/bml/visa_student_blue.png"
else -> "cards/bml/defaultcard.png"
"C8102" -> "cards/bml/master_odiveriyaa.png"
"C8010", "C8011" -> "cards/bml/master_platinum.png"
"C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png"
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
"C1017" -> "cards/bml/visa_infinite.png"
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
"C8925", "C8926" -> "cards/bml/visa_student_blue.png"
"C1071", "C1073", "C1061", "C1063", "C1161", "C1163" -> "cards/bml/master.png"
"C1070", "C1072", "C1059", "C1062", "C1159", "C1162" -> "cards/bml/master_prepaid_business.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" />
+10
View File
@@ -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>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@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>
+37 -13
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_height="match_parent"
android:background="@android:color/black">
@@ -11,26 +12,49 @@
<LinearLayout
android:id="@+id/btnContainer"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="48dp"
android:orientation="horizontal">
android:orientation="vertical"
android:paddingHorizontal="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPickImage"
style="@style/Widget.Material3.Button.TonalButton"
<com.google.android.material.slider.Slider
android:id="@+id/zoomSlider"
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_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/qr_pick_image" />
android:layout_gravity="center_horizontal"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCancel"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/back" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPickImage"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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>
+23 -7
View File
@@ -6,13 +6,29 @@
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
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>
@@ -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 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardPendingFinances"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardCornerRadius="12dp"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
@@ -55,6 +55,7 @@
</com.google.android.material.textfield.TextInputLayout>
<!-- Amount (optional) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount"
+29 -1
View File
@@ -101,6 +101,15 @@
</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
android:id="@+id/btnClearFromInfo"
android:layout_width="40dp"
@@ -218,10 +227,29 @@
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
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>
<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
android:id="@+id/btnClearToInfo"
android:layout_width="40dp"
+10
View File
@@ -14,6 +14,16 @@
android:gravity="center_vertical"
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 -->
<LinearLayout
android:layout_width="0dp"
@@ -3,38 +3,64 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<TextView
android:id="@+id/tvDropdownAccountName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivDropdownCardLogo"
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitCenter"
android:layout_marginEnd="10dp"
android:visibility="gone"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
xmlns:app="http://schemas.android.com/apk/res-auto" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="2dp">
android:orientation="vertical">
<TextView
android:id="@+id/tvDropdownAccountNumber"
android:layout_width="0dp"
android:id="@+id/tvDropdownAccountName"
android:layout_width="match_parent"
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:textAppearance="?attr/textAppearanceBodyMedium"
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>
@@ -47,4 +47,13 @@
</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>
+1
View File
@@ -88,6 +88,7 @@
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
<string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string>
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
<!-- Dashboard -->
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
+17
View File
@@ -189,6 +189,13 @@
<string name="login_detail_customer_id">Customer ID</string>
<string name="login_detail_id_card">ID Card</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="save">Save</string>
<string name="cancel">Cancel</string>
@@ -238,6 +245,15 @@
<string name="transfer_send_otp_via">Send verification code via</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 -->
<string name="contacts_empty">No contacts found</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="card_pay_qr">QR 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_freeze">Freeze</string>
<string name="card_action_block">Block</string>
+1
View File
@@ -2,4 +2,5 @@
<paths>
<cache-path name="receipt_cache" path="receipts/" />
<cache-path name="qr_cache" path="qr/" />
<cache-path name="profile_photo_tmp" path="." />
</paths>
+4 -4
View File
@@ -187,15 +187,15 @@ The app presents different identities to different backends, which is intentiona
| Backend | User-Agent sent |
|---|---|
| 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 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 API calls | `okhttp/4.12.0` |
| Ooredoo | No custom UA |
| 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.
@@ -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. |
| `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. |
| `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. |
| `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. |
+256
View File
@@ -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)**
---
&nbsp;
---
[← README](README.md) &nbsp;&nbsp;&nbsp; **Next →** [Business Profile OTP](02-business-otp.md)
+164
View File
@@ -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 15](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)**
---
&nbsp;
---
[← Login](01-login.md) &nbsp;&nbsp;&nbsp; **Next →** [OAuth Token](03-oauth-token.md)
+193
View File
@@ -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 |
---
&nbsp;
---
[← Business Profile OTP](02-business-otp.md) &nbsp;&nbsp;&nbsp; **Next →** [Dashboard](04-dashboard.md)
+244
View File
@@ -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.
---
&nbsp;
---
[← OAuth Token](03-oauth-token.md) &nbsp;&nbsp;&nbsp; **Next →** [User Info](05-userinfo.md)
+141
View File
@@ -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).
---
&nbsp;
---
[← Dashboard](04-dashboard.md) &nbsp;&nbsp;&nbsp; **Next →** [Account History](06-account-history.md)
+194
View File
@@ -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) |
---
&nbsp;
---
[← User Info](05-userinfo.md) &nbsp;&nbsp;&nbsp; **Next →** [Card Statement](07-card-statement.md)
+164
View File
@@ -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) |
---
&nbsp;
---
[← Account History](06-account-history.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer](08-transfer.md)
+235
View File
@@ -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.
---
&nbsp;
---
[← Card Statement](07-card-statement.md) &nbsp;&nbsp;&nbsp; **Next →** [Contacts](09-contacts.md)
+157
View File
@@ -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.
---
&nbsp;
---
[← Transfer](08-transfer.md) &nbsp;&nbsp;&nbsp; **Next →** [Account Validation](10-validate.md)
+175
View File
@@ -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 |
---
&nbsp;
---
[← Contacts](09-contacts.md) &nbsp;&nbsp;&nbsp; **Next →** [Foreign Limits](11-foreign-limits.md)
+147
View File
@@ -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) |
---
&nbsp;
---
[← Account Validation](10-validate.md)
+198
View File
@@ -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="{&quot;component&quot;:&quot;Login&quot;,&quot;props&quot;:{...}}">
```
To extract: find `data-page="..."`, unescape HTML entities (`&quot;``"`, `&amp;``&`, `&#39;``'`, `&lt;``<`, `&gt;``>`), 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 |
---
&nbsp;
---
> **Next →** [Login](01-login.md)
+5 -5
View File
@@ -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[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
| `device[model]` | `22101320I` | `Build.MODEL` |
| `device[manufacturer]` | `Xiaomi` | `Build.MANUFACTURER` |
| `device[model]` | `{model}` | `Build.MODEL` |
| `device[manufacturer]` | `{manufacturer}` | `Build.MANUFACTURER` |
| `device[isVirtual]` | `false` | |
| `device[serial]` | `unknown` | |
@@ -47,7 +47,7 @@ curl --request POST \
--header 'accept: application/json' \
--header 'accept-encoding: gzip, deflate, br' \
--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 'password=your_password' \
--form 'grant_type=auth_id' \
@@ -57,8 +57,8 @@ curl --request POST \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]=22101320I' \
--form 'device[manufacturer]=Xiaomi' \
--form 'device[model]={model}' \
--form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \
--form 'device[serial]=unknown'
```
+5 -5
View File
@@ -38,8 +38,8 @@ POST https://fahipay.mv/api/app/otp/
| `device[available]` | `true` | Same device fields as login — must match |
| `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
| `device[model]` | `22101320I` | |
| `device[manufacturer]` | `Xiaomi` | |
| `device[model]` | `{model}` | |
| `device[manufacturer]` | `{manufacturer}` | |
| `device[isVirtual]` | `false` | |
| `device[serial]` | `unknown` | |
@@ -57,7 +57,7 @@ curl --request POST \
--header 'accept: application/json' \
--header 'accept-encoding: gzip, deflate, br' \
--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 'channel=totp' \
--form 'action=login' \
@@ -68,8 +68,8 @@ curl --request POST \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]=22101320I' \
--form 'device[manufacturer]=Xiaomi' \
--form 'device[model]={model}' \
--form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \
--form 'device[serial]=unknown'
```
+6 -4
View File
@@ -1,6 +1,8 @@
# 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-encoding: gzip, deflate, br
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
@@ -64,8 +66,8 @@ All login and OTP requests include a standard set of device fields:
| `device[available]` | `true` | Always `true` |
| `device[platform]` | `Android` | Always `Android` |
| `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted |
| `device[model]` | `22101320I` | Device model string |
| `device[manufacturer]` | `Xiaomi` | Device manufacturer |
| `device[model]` | `{model}` | Device model string |
| `device[manufacturer]` | `{manufacturer}` | Device manufacturer |
| `device[isVirtual]` | `false` | Always `false` |
| `device[serial]` | `unknown` | Always `unknown` |
+234
View File
@@ -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 199 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 17 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 (~2324 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 |
---
&nbsp;
---
**Next →** [Login Flow](02-login.md)
+346
View File
@@ -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
[48] 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.
---
&nbsp;
---
[← Encryption](01-encryption.md) &nbsp;&nbsp;&nbsp; **Next →** [Accounts](03-accounts.md)
+137
View File
@@ -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.
---
&nbsp;
---
[← Login Flow](02-login.md) &nbsp;&nbsp;&nbsp; **Next →** [Transaction History](04-history.md)
+107
View File
@@ -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 |
---
&nbsp;
---
[← Accounts](03-accounts.md) &nbsp;&nbsp;&nbsp; **Next →** [Cards](05-cards.md)
+86
View File
@@ -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 }
```
---
&nbsp;
---
[← Transaction History](04-history.md) &nbsp;&nbsp;&nbsp; **Next →** [Financing](06-financing.md)
+106
View File
@@ -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.
---
&nbsp;
---
[← Cards](05-cards.md) &nbsp;&nbsp;&nbsp; **Next →** [Personal Profile](07-profile.md)
+92
View File
@@ -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.
---
&nbsp;
---
[← Financing](06-financing.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer](08-transfer.md)
+208
View File
@@ -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 |
---
&nbsp;
---
[← Personal Profile](07-profile.md) &nbsp;&nbsp;&nbsp; **Next →** [Contacts](09-contacts.md)
+229
View File
@@ -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.
---
&nbsp;
---
[← Transfer](08-transfer.md)
-172
View File
@@ -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 03 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.
-345
View File
@@ -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 199 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 17 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` (17):
- 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 ~1M16M, approximately 2324 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`.
-180
View File
@@ -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 |
-449
View File
@@ -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 |
+88
View File
@@ -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)
-123
View File
@@ -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.
-249
View File
@@ -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`.
-109
View File
@@ -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.
-81
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB