Compare commits

..
9 Commits
Author SHA1 Message Date
shihaam 2764c6e452 update mib api docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-23 05:38:33 +05:00
shihaam 740030e061 release 1.0.36
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m16s
2026-10-23 05:30:02 +05:00
shihaam 6b9ac5532b MIB now fetch users info (idcard, phone num, email) to display in settings
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-23 05:27:09 +05:00
shihaam 46edefe4d7 MIB v2 history, cards, contacts, transfers, contact edit 2026-10-23 05:15:43 +05:00
shihaam 870d3eb0f6 MIB v2 login works
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-23 03:09:42 +05:00
shihaam 7ccd8aa4f5 untrack claude 2026-10-23 03:09:26 +05:00
shihaam 05ff5a829a upgrade mib plan
Auto Tag on Version Change / check-version (push) Successful in 8s
2026-10-23 02:26:53 +05:00
shihaam d4805219f4 new version number because fayaz broke build
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 3m34s
2026-10-03 01:50:27 +05:00
shihaam 4c680ad22c remove unused transfer_select_service translation 2026-10-03 01:49:59 +05:00
41 changed files with 1240 additions and 992 deletions
+2
View File
@@ -1,3 +1,4 @@
.envrc
*.iml
.gradle
/local.properties
@@ -23,3 +24,4 @@ docs/ooredooapi/tmp
tmp
app/key.jks
.kotlin/*
.claude/*
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 35
versionName = "1.0.34"
versionCode = 36
versionName = "1.0.36"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -11,7 +11,7 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private const val MIB_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
class MibActivityHistoryClient {
@@ -35,7 +35,7 @@ class MibActivityHistoryClient {
start: Int,
end: Int
): FetchResult {
val cookieHeader = "mbmodel=IOS-1.0; " +
val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
@@ -15,7 +15,7 @@ data class MibCardActionResult(
class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
@@ -25,7 +25,7 @@ class MibCardsClient {
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
@@ -1,54 +1,52 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibContactsClient {
class MibContactsClient(private val flow: MibLoginFlow) {
private val TAG = "MibContactsClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> =
fetchContactsAndCategories(session).second
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchContacts(session: MibSession): List<MibBeneficiary> =
fetchContactsAndCategories(session).first
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withSessionHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/beneficiary?dashurl=1")
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> {
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getCategories")
.post(FormBody.Builder().build())
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return emptyList()
parseCategories(response.body?.string() ?: return emptyList())
/**
* Pages through B41. Every page also carries the full category list.
* start/end select rows by their 1-based `rnum`, both ends inclusive.
*/
fun fetchContactsAndCategories(session: MibSession): Pair<List<MibBeneficiary>, List<MibBeneficiaryCategory>> {
val all = mutableListOf<MibBeneficiary>()
val seen = mutableSetOf<String>()
var categories = emptyList<MibBeneficiaryCategory>()
val pageSize = 10 // the official app always requests 10-row windows
var start = 1
while (true) {
val resp = flow.request(session, "B41") {
put("start", start)
put("end", start + pageSize - 1)
put("benefType", "A")
put("search", "")
put("includeCount", 1)
put("searchCategoryId", 0)
put("sortBenef", "")
}
if (!resp.optBoolean("success")) break
if (start == 1) categories = parseCategories(resp)
val totalCount = resp.optString("total_count", "0").toIntOrNull() ?: 0
val arr = resp.optJSONArray("data") ?: break
val page = (0 until arr.length()).map { parseContact(arr.getJSONObject(it)) }
for (c in page) if (seen.add(c.benefNo)) all.add(c)
val isLast = arr.length() == 0 ||
arr.getJSONObject(arr.length() - 1).optString("last") == "1"
if (isLast || all.size >= totalCount) break
start += pageSize
}
return Pair(all, categories)
}
private fun parseCategories(json: String): List<MibBeneficiaryCategory> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { i ->
private fun parseCategories(resp: JSONObject): List<MibBeneficiaryCategory> {
val arr = resp.optJSONArray("categories") ?: return emptyList()
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
id = o.getString("id"),
@@ -56,56 +54,11 @@ class MibContactsClient {
numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 0
)
}
} catch (e: Exception) {
emptyList()
}
}
fun fetchContacts(session: MibSession): List<MibBeneficiary> {
val all = mutableListOf<MibBeneficiary>()
var page = 1
val pageSize = 100
while (true) {
val start = (page - 1) * pageSize + 1
val end = page * pageSize
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
val (contacts, totalCount) = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return all
parseContacts(response.body?.string() ?: return all)
}
all.addAll(contacts)
if (all.size >= totalCount || contacts.isEmpty()) break
page++
}
return all
}
private fun parseContacts(json: String): Pair<List<MibBeneficiary>, Int> {
return try {
val obj = JSONObject(json)
val totalCount = obj.optString("total_count", "0").toIntOrNull() ?: 0
val arr = obj.getJSONArray("data")
val contacts = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
private fun parseContact(o: JSONObject): MibBeneficiary {
val hash = o.optString("customerImgHash")
MibBeneficiary(
return MibBeneficiary(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName")
@@ -121,15 +74,10 @@ class MibContactsClient {
benefCategoryId = o.optString("benefCategoryID", "0")
)
}
Pair(contacts, totalCount)
} catch (e: Exception) {
Pair(emptyList(), 0)
}
}
/** Creates a beneficiary via B43. The server rejects nicknames longer than ~20 chars. */
fun createContact(
session: MibSession,
benefType: String, // "I" = MIB internal, "L" = local/BML
bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String,
benefName: String,
@@ -138,82 +86,62 @@ class MibContactsClient {
categoryId: String = "0",
imageBase64: String = ""
): Boolean {
val body = FormBody.Builder()
.add("benefType", benefType)
.add("imageSet", if (imageBase64.isNotBlank()) "1" else "0")
.add("benefAccount", benefAccount)
.add("benefIban", "")
.add("benefName", benefName)
.add("nickName", nickName)
.add("transferCy", transferCy)
.add("transferCySwift", "840")
.add("benefAddress", "")
.add("benefCity", "")
.add("benefCountry", "4")
.add("benefBankSwift", "")
.add("bankNo", bankNo.toString())
.add("benefBankName", "")
.add("benefBankBranch", "")
.add("benefBankAddress", "")
.add("benefBankCity", "")
.add("benefBankCountry", "4")
.add("intBankSwift", "")
.add("intBankName", "")
.add("intBankAddress", "")
.add("intBankBranch", "")
.add("intBankCity", "")
.add("intBankCountry", "4")
.add("categoryId", categoryId)
.add("email", "")
.add("contactNumber", "")
.add("website", "")
.add("image", imageBase64)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/createLocalBeneficiary")
.post(body)
.withSessionHeaders(session)
.header("Referer", "$BASE_WV_URL/beneficiary/createNew")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
val (bankName, bankSwift) = bankInfo(bankNo)
val resp = flow.request(session, "B43") {
put("benefName", benefName)
put("benefAccount", benefAccount)
put("bankNo", bankNo.toString())
put("nickName", nickName)
put("transferCy", transferCy)
put("categoryId", categoryId)
if (imageBase64.isNotBlank()) put("image", imageBase64)
put("bankName", bankName)
put("bankSwift", bankSwift)
}
return resp.optBoolean("success")
}
fun deleteContact(session: MibSession, benefNo: String): Boolean {
val body = FormBody.Builder().add("benefNo", benefNo).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
/** Updates a beneficiary via B52. A blank [imageBase64] keeps the current photo. */
fun updateContact(
session: MibSession,
benefNo: String,
bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String,
benefName: String,
nickName: String,
transferCy: String = "462",
categoryId: String = "0",
imageBase64: String = ""
): Boolean {
val (bankName, bankSwift) = bankInfo(bankNo)
val resp = flow.request(session, "B52") {
put("benefName", benefName)
put("benefAccount", benefAccount)
put("bankNo", bankNo.toString())
put("nickName", nickName)
put("transferCy", transferCy)
put("categoryId", categoryId)
put("benefNo", benefNo)
put("imageChanged", if (imageBase64.isNotBlank()) "1" else "0")
if (imageBase64.isNotBlank()) put("image", imageBase64)
put("bankName", bankName)
put("bankSwift", bankSwift)
}
return resp.optBoolean("success")
}
/** Bank name/SWIFT as listed by B49 for the bank IDs we support. */
private fun bankInfo(bankNo: Int): Pair<String, String> = when (bankNo) {
2 -> "Maldives Islamic Bank" to "MADVMVMV"
else -> "Bank of Maldives PLC" to "MALBMVMV"
}
fun deleteContact(session: MibSession, benefNo: String): Boolean =
flow.request(session, "B48") { put("benefNo", benefNo) }.optBoolean("success")
fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
val body = FormBody.Builder()
.add("imageHash", imageHash)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getProfileImage")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
try {
val json = response.body?.string() ?: return null
JSONObject(json).optString("profileImage").takeIf { it.isNotBlank() }
} catch (e: Exception) {
null
}
}
val resp = flow.request(session, "B46") { put("imageHash", imageHash) }
if (!resp.optBoolean("success")) return null
return resp.optString("profileImage").takeIf { it.isNotBlank() }
}
}
@@ -9,7 +9,7 @@ import kotlin.math.ceil
class MibFinancingClient {
private val TAG = "MibFinancingClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@@ -17,7 +17,7 @@ class MibFinancingClient {
.build()
fun fetchFinancing(session: MibSession): List<MibFinanceDeal> {
val cookieHeader = "mbmodel=IOS-1.0; " +
val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
@@ -1,28 +1,9 @@
package sh.sar.basedbank.api.mib
import android.os.Build
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 {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
class MibHistoryClient(private val flow: MibLoginFlow) {
/**
* Fetches transaction history for a single MIB account.
* Fetches transaction history for a single MIB account via the encrypted `A84` route.
* @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
* @return Pair of (transactions, totalCount)
*/
@@ -33,37 +14,16 @@ class MibHistoryClient {
start: Int = 1,
pageSize: Int = 20
): Pair<List<Transaction>, Int> {
val body = FormBody.Builder()
.add("accountNo", accountNo)
.add("trxNo", "")
.add("trxType", "0")
.add("sortTrx", "date")
.add("sortDir", "desc")
.add("fromDate", "")
.add("toDate", "")
.add("start", start.toString())
.add("end", (start + pageSize - 1).toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxAccounts/trxHistory")
.post(body)
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//accountDetails?trxh=1&dashurl=1&accountNo=$accountNo")
.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) }
// A84 uses a 0-based inclusive range: first page is start=0, end=19
val json = flow.request(session, "A84") {
put("accountNo", accountNo)
put("start", start - 1)
put("end", start - 1 + pageSize - 1)
put("includeCount", true)
put("trxType", 0)
put("sortTrx", "date")
put("sortDir", "desc")
}
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
@@ -84,7 +44,6 @@ class MibHistoryClient {
source = "MIB"
)
}
Pair(transactions, total)
}
return Pair(transactions, total)
}
}
@@ -7,6 +7,10 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
@@ -16,7 +20,7 @@ class SessionExpiredException : Exception("MIB session expired")
class MibLoginFlow(private val credentialStore: CredentialStore) {
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
private val BASE_URL = "https://faisamobilex-smvc-v2.mib.com.mv/index/"
/** The active session after a successful login, usable for subsequent WebView requests. */
var lastSession: MibSession? = null
@@ -26,6 +30,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var lastProfiles: List<MibProfile> = emptyList()
private set
/** Full name and email from the last A41 login response. */
var lastFullName: String = ""
private set
var lastEmail: String = ""
private set
/** Called after automatic session recovery so callers can update their session reference. */
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
@@ -42,8 +52,9 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.writeTimeout(30, TimeUnit.SECONDS)
.addNetworkInterceptor { chain ->
val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0")
.header("User-Agent", "android/2.0")
.header("Accept", "application/json")
.header("X-Request-ID", generateRequestId())
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.build()
chain.proceed(req)
@@ -144,6 +155,8 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
}
val profiles = parseProfiles(loginResp)
lastFullName = loginResp.optString("fullName").takeIf { it != "null" } ?: ""
lastEmail = loginResp.optString("email").takeIf { it != "null" } ?: ""
lastSession = session2
lastProfiles = profiles // keep ALL profiles so settings can show them all
@@ -300,6 +313,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
put("xxid", session.xxid)
}
/**
* Sends an encrypted `sfunc=n` request for [routePath] with the base fields plus [fields].
* Expired sessions are recovered the same way as login requests.
*/
fun request(session: MibSession, routePath: String, fields: JSONObject.() -> Unit = {}): JSONObject =
doRequest(session, baseData(session, routePath).apply(fields), "n")
/**
* Activates [profile] server-side via P47, setting the session role context so that
* subsequent WebView requests run under that profile.
@@ -424,6 +444,16 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
return sha256Upper(clientSalt + h2)
}
private fun generateRequestId(): String {
val sdf = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val timestamp = sdf.format(Date())
val millis = System.currentTimeMillis() % 1000
val randomHex = Random.nextBytes(8).joinToString("") { "%02x".format(it) }
return "$timestamp${millis.toString().padStart(3, '0')}-$randomHex"
}
companion object {
/** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */
fun hashPassword(password: String): String =
@@ -35,7 +35,8 @@ data class MibTransferResult(
val success: Boolean,
val trxId: String = "",
val date: String = "",
val errorMessage: String = ""
val errorMessage: String = "",
val reasonCode: String = ""
)
@@ -1,50 +1,87 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.util.CredentialStore
import java.util.concurrent.TimeUnit
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
val idCard: String
)
class MibProfileClient {
class MibProfileClient(private val flow: MibLoginFlow) {
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Name comes from the A41 login response; email, mobile and National ID from the
* Favara alias suggestions (`/ajaxAlias/getAliasTypes`), which belong to the active
* profile — so the personal profile is selected first.
*/
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val personal = flow.lastProfiles.firstOrNull { it.profileType == "0" }
if (personal != null) flow.switchProfile(session, personal)
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.get()
.header("Cookie", cookieHeader)
.build()
return try {
val resp = client.newCall(request).execute()
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
.url("$BASE_WV_URL/ajaxAlias/getAliasTypes")
.post(FormBody.Builder().build())
.header("Cookie", "mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}")
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
} catch (_: Exception) { null }
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/Alias/Alias?dashurl=1")
.build()
val json = client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: return null
try { JSONObject(body) } catch (_: Exception) { return null }
}
if (!json.optBoolean("success")) return null
// Alias type ids: 0 = mobile number, 1 = email, 3 = National ID
val aliases = json.optJSONArray("data")
fun alias(id: Int): String {
if (aliases == null) return ""
for (i in 0 until aliases.length()) {
val o = aliases.getJSONObject(i)
if (o.optInt("id", -1) == id) return o.optString("suggestedAlias").takeIf { it != "null" } ?: ""
}
return ""
}
return MibPersonalProfile(
fullName = flow.lastFullName.ifBlank { personal?.name ?: "" },
email = alias(1).ifBlank { flow.lastEmail },
mobile = alias(0),
idCard = alias(3)
)
}
/** Fetches and caches the user's details for [loginId] unless a v2 fetch is already cached. */
fun cacheIfMissing(store: CredentialStore, loginId: String, session: MibSession) {
if (store.loadMibUserProfile(loginId)?.idCard != null) return
val p = fetchPersonalProfile(session) ?: return
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = p.fullName,
username = loginId,
email = p.email,
mobile = p.mobile,
enrolled = "",
idCard = p.idCard
))
}
}
@@ -1,42 +1,12 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibLookupException(message: String) : Exception(message)
class MibTransferClient {
private val TAG = "MibTransferClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/transfer/quick")
class MibTransferClient(private val flow: MibLoginFlow) {
/**
* Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal).
* currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank.
* Execute a transfer. bankNo=2 → MIB internal (T50), bankNo=3 → local/BML via Favara (T51).
* currencyCode: "462"=MVR, "840"=USD.
*/
fun transfer(
session: MibSession,
@@ -49,157 +19,79 @@ class MibTransferClient {
purpose: String,
otp: String
): MibTransferResult {
val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal"
val body = FormBody.Builder()
.add("benefName", benefName)
.add("benefNo", "0")
.add("fromAccountNo", fromAccount)
.add("benefAccountNo", toAccount)
.add("transferCy", currencyCode)
.add("benefCurrencyCode", currencyCode)
.add("amount", amount)
.add("bankNo", bankNo.toString())
.add("purpose", purpose.ifBlank { "-" })
.add("otp", otp)
.add("otpType", "3")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxTransfer/$endpoint")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) {
MibTransferResult(
val json = flow.request(session, if (bankNo == 2) "T50" else "T51") {
put("benefName", benefName)
put("benefNo", "0")
put("fromAccountNo", fromAccount)
put("benefAccountNo", toAccount)
put("transferCy", currencyCode)
put("benefCurrencyCode", currencyCode)
put("amount", amount)
put("bankNo", bankNo.toString())
put("purpose", purpose)
put("otp", otp)
put("otpType", "3")
}
if (!json.optBoolean("success")) {
return MibTransferResult(
success = false,
errorMessage = json?.optString("reasonText")?.ifBlank { null } ?: "Transfer failed"
errorMessage = json.optString("reasonText").ifBlank { "Transfer failed" },
reasonCode = json.optString("reasonCode")
)
} else {
}
val data = json.optJSONArray("data")?.optJSONObject(0)
MibTransferResult(
return MibTransferResult(
success = true,
trxId = data?.optString("trxId") ?: "",
date = data?.optString("date") ?: ""
)
}
}
}
/**
* Routes the lookup to the correct endpoint based on input format:
* Resolves a destination via B40, which takes any supported input:
*
* - 15 digits starting with 7 → IPS account lookup (BML / local bank)
* - 17 digits starting with 9 → MIB internal account name lookup
* - 13 digits starting with 7 → BML / local bank account
* - 17 digits starting with 9 → MIB account
* - 7 digits starting with 7 or 9,
* A + 6 digits,
* or email address → Favara alias lookup
* or email address → Favara alias
*
* The format check only avoids sending inputs B40 can't resolve.
*/
fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
val trimmed = input.trim()
return when {
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed)
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed)
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.contains("@") -> lookupAlias(session, trimmed)
else -> throw MibLookupException("Unrecognized account number format")
}
}
// B40 only resolves Favara IDs with an uppercase "A" ("a239225" fails, "A239225" works)
val trimmed = input.trim().let { if (it.matches(Regex("^a\\d{6}$"))) it.uppercase() else it }
val supported = trimmed.matches(Regex("^7\\d{12}$")) ||
trimmed.matches(Regex("^9\\d{16}$")) ||
trimmed.matches(Regex("^[79]\\d{6}$")) ||
trimmed.matches(Regex("^[Aa]\\d{6}$")) ||
trimmed.contains("@")
if (!supported) throw MibLookupException("Unrecognized account number format")
/** BML / local bank: 15-digit account starting with 7. */
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("benefAccount", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getIPSAccount")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
val json = flow.request(session, "B40") { put("search", trimmed) }
val match = json.optJSONArray("data")?.optJSONObject(0)
if (!json.optBoolean("success") || match == null) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
json.optString("reasonText").trim().ifBlank { "Account not found" }
)
}
MibIpsAccountInfo(
accountName = json.optString("accountName").trim(),
val accountNumber = match.optString("benefAccount")
// B40 reports MIB accounts either as "MIB" or by BIC; callers expect the BIC
val bankId = match.optString("bankCode").let { if (it == "MIB") "MADVMVMV" else it }
val currency = match.optString("transferCyDesc").takeIf { it == "MVR" || it == "USD" }
?: if (bankId == "MADVMVMV") "" else "MVR" // cross-bank lookups only resolve MVR accounts
return MibIpsAccountInfo(
accountName = match.optString("benefName").trim(),
accountNumber = accountNumber,
bankId = json.optString("bankBic"),
// MIB IPS only returns success for MVR cross-bank accounts;
// USD cross-bank accounts fail this lookup entirely.
currency = "MVR"
)
}
}
/** MIB internal: 17-digit account starting with 9. */
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("accountNo", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getAccountName")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
// accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: ""
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
val currency = when (currencyCode) {
"840" -> "USD"
"462" -> "MVR"
else -> ""
}
MibIpsAccountInfo(
accountName = name.trim(),
accountNumber = accountNumber,
bankId = "MADVMVMV", // MIB
bankId = bankId,
currency = currency
)
}
}
/** Favara alias: 7-digit shortcode (7/9 prefix), A-format ID, or email. */
private fun lookupAlias(session: MibSession, aliasName: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("aliasName", aliasName).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getAlias")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
val data = json.getJSONObject("data")
val cdtrAcct = data.getJSONObject("CdtrAcct")
MibIpsAccountInfo(
accountName = data.optString("BfyNm").trim(),
accountNumber = cdtrAcct.optString("Acct"),
bankId = cdtrAcct.optString("FinInstnId")
)
}
companion object {
/** reasonCode for a wrong OTP, e.g. one from the previous window. */
const val REASON_INVALID_OTP = "213"
/** reasonCode for an OTP the server has already accepted once. */
const val REASON_OTP_ALREADY_USED = "707"
}
}
@@ -294,7 +294,7 @@ class AccountHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
@@ -18,11 +18,13 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -32,6 +34,7 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile
@@ -67,6 +70,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedCategoryId: String = "0"
private var categories: List<BankContactCategory> = emptyList()
// Set when editing an existing MIB contact instead of adding a new one
private var editContact: BankContact? = null
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult
encodeImage(uri)
@@ -90,7 +96,49 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (selectedDest?.isBml == false) setupCategoryDropdown()
}
applyPrefillArgs()
if (!applyEditArgs()) applyPrefillArgs()
}
/** Switches the sheet into edit mode when opened via [forEdit]. Returns true if it did. */
private fun applyEditArgs(): Boolean {
val args = arguments ?: return false
val benefNo = args.getString(ARG_EDIT_BENEF_NO) ?: return false
val profileId = args.getString(ARG_EDIT_PROFILE_ID) ?: return false
val contact = viewModel.contacts.value?.firstOrNull { it.benefNo == benefNo && it.profileId == profileId }
val dest = destinations.firstOrNull { !it.isBml && it.mibProfile?.profileId == profileId }
if (contact == null || dest == null) {
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
dismiss()
return true
}
editContact = contact
selectedCategoryId = contact.benefCategoryId
categories = viewModel.contactCategories.value?.filter { it.id != "BML" } ?: emptyList()
binding.tvTitle.setText(R.string.contact_edit_title)
selectedDest = dest
binding.actvDestination.setText(dest.label, false)
binding.tilDestination.isEnabled = false
binding.actvGroup.setText(
categories.firstOrNull { it.id == contact.benefCategoryId }?.categoryName
?: getString(R.string.contact_no_group), false
)
updateMibOnlyVisibility()
mibLookupAccount = contact.benefAccount
binding.tvResultName.text = contact.benefName
binding.tvResultBank.text = "${contact.benefBankName} · ${contact.benefAccount}"
val photo = contact.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
binding.ivResultAvatar.setImageBitmap(photo ?: makeInitialsBitmap(contact.benefName, contact.bankColor))
if (photo != null) binding.ivContactImage.setImageBitmap(photo)
binding.cardResult.visibility = View.VISIBLE
binding.tilAccount.visibility = View.GONE
binding.btnClearResult.visibility = View.GONE
binding.etAlias.setText(contact.benefNickName)
binding.etCurrency.setText(contact.transferCyDesc)
updateSaveState()
return true
}
private fun applyPrefillArgs() {
@@ -201,6 +249,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE
binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE
if (isMib) setupCategoryDropdown()
updateSaveState()
}
/**
* Save is enabled once an account is resolved. MIB rejects nicknames longer than
* [MIB_MAX_ALIAS] characters, so show that under the alias field and keep Save disabled.
*/
private fun updateSaveState() {
val alias = binding.etAlias.text?.toString()?.trim() ?: ""
val tooLong = selectedDest?.isBml == false && alias.length > MIB_MAX_ALIAS
binding.tilAlias.error = if (tooLong) getString(R.string.contact_alias_mib_max, MIB_MAX_ALIAS) else null
binding.btnSave.isEnabled = !tooLong && mibLookupAccount != null
}
private fun setupCategoryDropdown() {
@@ -307,8 +367,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.anyMibSession() ?: return null
val mibFlow = app.anyMibFlow() ?: return null
return try {
val info = MibTransferClient().lookup(mibSess, input)
val info = MibTransferClient(mibFlow).lookup(mibSess, input)
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
@@ -328,8 +389,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val profile = dest.mibProfile ?: return null
val mibResult = try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input)
val flow = app.mibFlowFor(loginId)
flow.switchProfile(mibSess, profile)
val info = MibTransferClient(flow).lookup(mibSess, input)
BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
validationType = "MIB",
@@ -398,7 +460,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.etCurrency.setText(validation.currency)
binding.btnClearResult.setOnClickListener { clearLookupResult() }
binding.btnSave.isEnabled = true
updateSaveState()
}
private fun clearLookupResult() {
@@ -438,6 +500,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun setupSaveButton() {
binding.btnSave.setOnClickListener { performSave() }
binding.etAlias.addTextChangedListener(afterTextChanged = { updateSaveState() })
}
private fun performSave() {
@@ -447,6 +510,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = "Alias is required"
return
}
if (!dest.isBml && alias.length > MIB_MAX_ALIAS) {
updateSaveState()
return
}
binding.tilAlias.error = null
binding.btnSave.isEnabled = false
@@ -454,11 +521,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
if (dest.isBml) saveToBml(alias) else saveToMib(alias)
when {
editContact != null -> updateMib(alias)
dest.isBml -> saveToBml(alias)
else -> saveToMib(alias)
}
}
if (success) {
Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show()
reloadSavedProfileContacts(dest)
// Same reload as pull-to-refresh on the contacts page
(activity as? HomeActivity)?.loadAllContacts(showIndicator = true)
dismiss()
} else {
binding.btnSave.isEnabled = true
@@ -495,15 +567,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val isMibInternal = account.matches(Regex("^9\\d{16}$"))
val benefType = if (isMibInternal) "I" else "L"
val bankNo = if (isMibInternal) 2 else 3
val name = bmlLookup?.name ?: ""
return try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact(
MibContactsClient(app.mibFlowFor(loginId)).createContact(
session = mibSess,
benefType = benefType,
bankNo = bankNo,
benefAccount = account,
benefName = name,
@@ -515,6 +585,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} catch (_: Exception) { false }
}
private fun updateMib(alias: String): Boolean {
val contact = editContact ?: return false
val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val bankNo = if (contact.benefAccount.matches(Regex("^9\\d{16}$"))) 2 else 3
return try {
val flow = app.mibFlowFor(loginId)
flow.switchProfile(mibSess, profile)
MibContactsClient(flow).updateContact(
session = mibSess,
benefNo = contact.benefNo,
bankNo = bankNo,
benefAccount = contact.benefAccount,
benefName = contact.benefName,
nickName = alias,
transferCy = transferCy,
categoryId = selectedCategoryId,
imageBase64 = selectedImageBase64
)
} catch (_: Exception) { false }
}
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 }
@@ -532,35 +629,6 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return bm
}
private fun reloadSavedProfileContacts(dest: DestinationOption) {
// Use activity scope so the reload continues after sheet dismisses
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try {
if (dest.isBml) {
val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged)
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else {
val profile = dest.mibProfile ?: return@launch
val mibLoginId = dest.mibLoginId ?: return@launch
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.profileId != profile.profileId } + fresh
viewModel.contacts.postValue(merged)
val allMib = merged.filter { it.benefCategoryId != "BML" }
ContactsCache.save(requireContext(), allMib, viewModel.contactCategories.value ?: emptyList())
}
} catch (_: Exception) { }
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
@@ -570,10 +638,23 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
// MIB rejects longer nicknames with "invalid nick name"
private const val MIB_MAX_ALIAS = 20
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_RECIPIENT_NAME = "recipient_name"
private const val ARG_CURRENCY = "currency"
private const val ARG_EDIT_BENEF_NO = "edit_benef_no"
private const val ARG_EDIT_PROFILE_ID = "edit_profile_id"
/** Opens the sheet to edit an existing MIB contact (alias, group, photo). */
fun forEdit(contact: ContactDisplay) = AddContactSheetFragment().apply {
arguments = Bundle().apply {
putString(ARG_EDIT_BENEF_NO, contact.id)
putString(ARG_EDIT_PROFILE_ID, contact.profileId)
}
}
fun newInstance(
bmlProfileId: String? = null,
@@ -77,8 +77,16 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
}
binding.btnEdit.setOnClickListener {
// Only contacts saved in MIB can be edited for now
val isMibContact = contact.network == TransferNetwork.MIB || contact.network == TransferNetwork.LOCAL
val parent = contactsFragment
if (isMibContact && parent != null) {
dismiss()
AddContactSheetFragment.forEdit(contact).show(parent.childFragmentManager, "edit_contact")
} else {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
}
binding.btnDelete.setOnClickListener {
val parent = contactsFragment
dismiss()
@@ -356,7 +356,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val base64 = if (hash in profileImageHashes) {
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -143,6 +143,8 @@ class ContactsFragment : Fragment() {
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
// triggerRefresh() only covers accounts; reload contacts from the banks too
(activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.isRefreshing = false
}
@@ -257,7 +259,7 @@ class ContactsFragment : Fragment() {
return
}
val sess = session ?: return
val client = MibContactsClient()
val client = MibContactsClient(app.anyMibFlow() ?: return)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -30,8 +30,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -61,6 +63,7 @@ import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibFinanceDeal
@@ -428,10 +431,10 @@ class HomeActivity : AppCompatActivity() {
for (session in sessions) {
withContext(Dispatchers.IO) {
try {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
val cookieHeader = "mbmodel=AND-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
.url("https://faisamobilex-wv-v2.mib.com.mv/aProfile/keepAlive")
.post(ByteArray(0).toRequestBody())
.header("Cookie", cookieHeader)
.header("User-Agent", "okhttp/4.11.0")
@@ -819,6 +822,8 @@ fun applyNavLabelVisibility() {
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
// Logins saved before v2 have no cached name/email/mobile/ID card yet
try { MibProfileClient(flow).cacheIfMissing(store, loginId, flow.lastSession!!) } catch (_: Exception) { }
accounts
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
@@ -1210,10 +1215,10 @@ fun applyNavLabelVisibility() {
}
}
private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return
private fun refreshBmlContacts(app: BasedBankApp): Job? {
if (app.bmlSessions.isEmpty()) return null
val store = CredentialStore(this)
lifecycleScope.launch {
return lifecycleScope.launch {
try {
val allBmlContacts = withContext(Dispatchers.IO) {
store.getBmlLoginIds().flatMap { loginId ->
@@ -1233,7 +1238,8 @@ fun applyNavLabelVisibility() {
}
}
fun loadAllContacts() {
/** Loads contacts from cache, then refreshes them from every bank. [showIndicator] shows the top refresh bar until done. */
fun loadAllContacts(showIndicator: Boolean = false) {
val app = application as BasedBankApp
val store = sh.sar.basedbank.util.CredentialStore(this)
// Populate ViewModel from cache immediately if empty
@@ -1249,16 +1255,24 @@ fun applyNavLabelVisibility() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
}
// Refresh all banks in background
val jobs = mutableListOf<Job>()
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))?.let { jobs.add(it) }
}
refreshBmlContacts(app)?.let { jobs.add(it) }
for ((_, session) in app.fahipaySessions) jobs.add(refreshFahipayContacts(session))
if (showIndicator && jobs.isNotEmpty()) {
setRefreshing(true)
lifecycleScope.launch {
jobs.joinAll()
setRefreshing(false)
}
}
refreshBmlContacts(app)
for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
}
private fun refreshFahipayContacts(session: FahipaySession) {
lifecycleScope.launch {
private fun refreshFahipayContacts(session: FahipaySession): Job {
return lifecycleScope.launch {
try {
val groups = withContext(Dispatchers.IO) {
FahipayContactsClient().fetchContacts(session)
@@ -1288,11 +1302,11 @@ fun applyNavLabelVisibility() {
return result
}
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>): Job? {
if (profiles.isEmpty()) return null
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient()
lifecycleScope.launch {
val contactsClient = MibContactsClient(flow)
return lifecycleScope.launch {
try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
@@ -1302,10 +1316,12 @@ fun applyNavLabelVisibility() {
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) {
val (profileContacts, profileCategories) =
contactsClient.fetchContactsAndCategories(session)
for (cat in profileCategories) {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
for (contact in profileContacts) {
if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId))
}
@@ -353,15 +353,16 @@ class OtpFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) {
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
try { MibProfileClient(flow).fetchPersonalProfile(session) } catch (_: Exception) { null }
}
if (profile != null) {
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName,
username = profile.username,
username = loginId,
email = profile.email,
mobile = profile.mobile,
enrolled = profile.enrolled
enrolled = "",
idCard = profile.idCard
))
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
@@ -668,10 +668,13 @@ class SettingsLoginsFragment : Fragment() {
scroll.addView(container)
// Account info lines
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $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?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
@@ -302,7 +302,7 @@ class TransferHistoryFragment : Fragment() {
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
@@ -382,7 +382,7 @@ class TransferHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
@@ -250,7 +250,7 @@ class TransferReceiptFragment : Fragment() {
val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -8,6 +8,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibTransferClient
@@ -40,6 +41,10 @@ class MibTransferHandler(
val session: MibSession?
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
/** The login flow matching [session], used for encrypted API calls. */
private val flow: MibLoginFlow?
get() = currentSource()?.loginTag?.removePrefix("mib_")?.let { app.mibFlowFor(it) } ?: app.anyMibFlow()
/** Any logged-in MIB session, ignoring which source is selected. */
val anySession: MibSession? get() = app.anyMibSession()
@@ -54,8 +59,9 @@ class MibTransferHandler(
*/
fun lookupDestination(accountNumber: String): LookupOutcome {
val sess = session ?: return LookupOutcome(null, notFound())
val flow = flow ?: return LookupOutcome(null, notFound())
return try {
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
LookupOutcome(MibTransferClient(flow).lookup(sess, accountNumber), null)
} catch (e: MibLookupException) {
LookupOutcome(null, e.message)
} catch (_: Exception) {
@@ -69,7 +75,8 @@ class MibTransferHandler(
*/
fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return ""
return try { MibTransferClient().lookup(sess, accountNumber).currency }
val flow = flow ?: return ""
return try { MibTransferClient(flow).lookup(sess, accountNumber).currency }
catch (_: Exception) { "" }
}
@@ -88,7 +95,7 @@ class MibTransferHandler(
val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash)
} else {
MibContactsClient().fetchProfileImageBase64(session, hash)
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(session, hash)
}
if (base64 == null) null else {
val bytes = Base64.decode(base64, Base64.DEFAULT)
@@ -100,8 +107,9 @@ class MibTransferHandler(
/**
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
* "CONNECTIVITY" when the network was unreachable.
* TOTP. If MIB rejects the OTP as invalid or already used, waits for the next OTP window and
* retries once; a second rejection is returned as a failure. Blocking — call from IO.
* Returns `(ok, message, receipt)`; the message is "CONNECTIVITY" when the network was unreachable.
*/
fun doTransfer(
src: BankAccount,
@@ -114,8 +122,7 @@ class MibTransferHandler(
): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val loginId = src.loginTag.removePrefix("mib_")
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
val otpSeed = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR"
@@ -145,7 +152,9 @@ class MibTransferHandler(
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val result = MibTransferClient().transfer(
val client = MibTransferClient(app.mibFlowFor(loginId))
val send = { otp: String ->
client.transfer(
session = sess,
fromAccount = src.accountNumber,
toAccount = destAccount,
@@ -156,6 +165,13 @@ class MibTransferHandler(
purpose = remarks,
otp = otp
)
}
var result = send(Totp.generate(otpSeed))
if (!result.success && result.reasonCode in OTP_REJECTED_CODES) {
// The code was stale or already spent; the next window's code is always fresh
Thread.sleep(Totp.millisUntilNextPeriod() + OTP_WINDOW_MARGIN_MS)
result = send(Totp.generate(otpSeed))
}
if (result.success) {
val receipt = TransferReceiptData(
bank = "MIB",
@@ -181,4 +197,13 @@ class MibTransferHandler(
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
}
}
private companion object {
val OTP_REJECTED_CODES = setOf(
MibTransferClient.REASON_INVALID_OTP,
MibTransferClient.REASON_OTP_ALREADY_USED
)
// Small delay past the window boundary so device and server clocks agree on the new code
const val OTP_WINDOW_MARGIN_MS = 1_000L
}
}
@@ -255,17 +255,7 @@ class CredentialsFragment : Fragment() {
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) {
flow.lastSession?.let { s ->
val profile = MibProfileClient().fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile(
fullName = profile.fullName,
username = profile.username,
email = profile.email,
mobile = profile.mobile,
enrolled = profile.enrolled
)
)
try { MibProfileClient(flow).cacheIfMissing(store, loginId, s) } catch (_: Exception) { }
}
}
store.saveMibProfiles(loginId, flow.lastProfiles)
@@ -27,14 +27,18 @@ object ContactManager {
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false
return try {
var flow = app.anyMibFlow() ?: return false
if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null)
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
if (profile != null && loginId != null) {
flow = app.mibFlowFor(loginId)
flow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.id)
}
MibContactsClient(flow).deleteContact(sess, contact.id)
} catch (_: Exception) { false }
}
}
@@ -669,7 +669,8 @@ class CredentialStore(context: Context) {
val username: String,
val email: String,
val mobile: String,
val enrolled: String
val enrolled: String,
val idCard: String? = null // null = not yet fetched from the v2 API
)
data class BmlUserProfile(
@@ -736,6 +737,7 @@ class CredentialStore(context: Context) {
put("email", p.email)
put("mobile", p.mobile)
put("enrolled", p.enrolled)
if (p.idCard != null) put("idCard", p.idCard)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
@@ -752,7 +754,8 @@ class CredentialStore(context: Context) {
username = o.optString("username"),
email = o.optString("email"),
mobile = o.optString("mobile"),
enrolled = o.optString("enrolled")
enrolled = o.optString("enrolled"),
idCard = if (o.has("idCard")) o.optString("idCard") else null
)
} catch (_: Exception) { null }
}
@@ -127,7 +127,7 @@ class HistoryFetcher(private val account: BankAccount) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
@@ -18,6 +18,12 @@ object Totp {
return otp.toString().padStart(digits, '0')
}
/** Milliseconds until the next code becomes current. */
fun millisUntilNextPeriod(periodSeconds: Long = 30): Long {
val periodMillis = periodSeconds * 1000L
return periodMillis - System.currentTimeMillis() % periodMillis
}
private fun hotp(key: ByteArray, counter: Long, digits: Int): Int {
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
val mac = Mac.getInstance("HmacSHA1")
@@ -12,6 +12,7 @@
android:padding="16dp">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
+2 -1
View File
@@ -261,7 +261,6 @@
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
<string name="transfer_select_service">ޚިދުމަތް އިޚްތިޔާރުކުރޭ</string>
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
@@ -331,6 +330,8 @@
<!-- Contact expand/delete -->
<string name="contact_edit">އުނިއިތުރުގެނޭ</string>
<string name="contact_edit_title">ކޮންޓެކްޓް އުނިއިތުރުގެނޭ</string>
<string name="contact_alias_mib_max">MIB ގައި ލިޔެވޭނީ ގިނަވެގެން %1$d އަކުރު</string>
<string name="contact_delete">ފުހެލާ</string>
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
+2
View File
@@ -341,6 +341,8 @@
<!-- Contact expand/delete -->
<string name="contact_edit">Edit</string>
<string name="contact_edit_title">Edit Contact</string>
<string name="contact_alias_mib_max">MIB allows a maximum of %1$d characters</string>
<string name="contact_delete">Delete</string>
<string name="contact_delete_title">Delete Contact</string>
<string name="contact_delete_message">Remove %s from your contacts?</string>
+11 -1
View File
@@ -57,6 +57,16 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
## Step-by-Step Reference
**Endpoint**: `POST https://faisamobilex-smvc-v2.mib.com.mv/index/`
**Headers** (on all requests):
```
User-Agent: android/2.0
Accept: application/json
X-Request-ID: {YYYYMMDDHHmmss}{milliseconds}-{16-char-hex}
Content-Type: application/x-www-form-urlencoded; charset=utf-8
```
### [0] Initial Key Exchange — `sfunc=r`
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
@@ -67,7 +77,7 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
"sfunc": "r",
"data": {
"cmod": "<G^A mod P as decimal string>",
"appId": "IOS17.2-<15 random alphanumeric chars>",
"appId": "AND34-<15 random alphanumeric chars>",
"routePath": "S40",
"sodium": "<random int in [1_000_000, 16_000_000)>",
"xxid": "<random 40-bit int as string>"
+38
View File
@@ -44,6 +44,44 @@ To switch between profiles (personal ↔ business), call `P47` again with the ot
---
## Refresh Accounts — `sfunc=n`, `routePath: A80`
**Key**: session key
Re-fetches the account list and balances for the **currently selected** profile without re-selecting it. No route-specific fields.
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "A80",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "101",
"reasonText": "Accounts retrieval success!",
"data": [ ... ],
"beneficiaryVersion": 0,
"menuVersion": "<hash>"
}
```
`data` has the same shape as the `accountBalance` array below. Some responses carry `responseCode: "3"`, `reasonCode: "90"`, `reasonText: "unknown error"` with `success: true` and a populated `data` array — treat `success` as authoritative.
---
## Profiles (from `A41` response)
The `A41` login init response includes `operatingProfiles`:
+74 -50
View File
@@ -1,41 +1,46 @@
# Transaction History
Fetch paginated transaction history for a single MIB account. Served from the WebView subdomain.
Fetch paginated transaction history for a single MIB account. Served from the encrypted API.
The account must belong to the **currently selected** profile — call `P47` for its profile first (see [03-accounts.md](03-accounts.md)).
---
## Endpoint
## Transaction History — `sfunc=n`, `routePath: A84`
**Key**: session key
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"accountNo": "90101000000001000",
"start": 0,
"end": 19,
"includeCount": true,
"trxType": 0,
"sortTrx": "date",
"sortDir": "desc",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "A84",
"xxid": "<session xxid>"
}
}
```
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) |
| `start` | `0` | First row (0-based, inclusive) |
| `end` | `19` | Last row (inclusive) |
| `includeCount` | `true` | Include `total_count` in response |
| `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
@@ -43,9 +48,9 @@ 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` |
| 1 | `0` | `19` |
| 2 | `20` | `39` |
| N | `(N-1)*20` | `N*20 - 1` |
Stop when total fetched equals `total_count` or `data` is empty.
@@ -56,25 +61,36 @@ Stop when total fetched equals `total_count` or `data` is empty.
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "103",
"reasonText": "Trx History Retrieval Success!",
"total_count": "87",
"pos": "0",
"data": [
{
"trxNumber": "TXN20260516001",
"trxDate": "2026-05-16",
"descr1": "Transfer Debit",
"baseAmount": "-500.00",
"accountNo": "90101000000001000",
"curCode": "462",
"curCodeDesc": "MVR",
"benefName": "Mohamed Ali",
"trxNumber2": "FT20260516001"
},
{
"trxNumber": "TXN20260515001",
"trxDate": "2026-05-15",
"descr1": "Transfer Credit",
"baseAmount": "1000.00",
"curCodeDesc": "MVR",
"benefName": "",
"trxNumber2": ""
"trxNumber": "1-158043140-93434633-1",
"trxNumber2": "93434633",
"trxDate": "2026-05-16 15:10:25",
"trxValDate": "2026-05-16 00:00:00",
"absAmount": "500",
"baseAmount": "-500",
"foreignAmount": "-500",
"descr1": "Favara Transfer Web",
"descr2": "<reference>",
"descr3": "<details>",
"remarks": "<details>",
"trxType": "803",
"AddDate": "2026-05-16 15:10:25",
"bankCode": "1",
"bankName": "Maldives Islamic Bank",
"benefName": "BML - MOHAMED ALI",
"otherAccountNo": "-",
"fromAcc": null,
"imageHash": null,
"bankColor": "#FE860E"
}
]
}
@@ -84,19 +100,27 @@ Stop when total fetched equals `total_count` or `data` is empty.
|---|---|---|
| `success` | `bool` | `true` on success |
| `total_count` | `string` | Total transaction count — parse to `int` |
| `pos` | `string` | Echo of the requested start row |
| `data` | `array` | Transactions for this page |
### Transaction Object
| Field | Type | Description |
| Field | Consumed | 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 |
| `trxNumber` | yes | Unique transaction ID |
| `trxDate` | yes | Transaction timestamp (`yyyy-MM-dd HH:mm:ss`) |
| `descr1` | yes | Transaction description — trim whitespace |
| `foreignAmount` | yes | Amount in the **account's** currency — **negative = debit, positive = credit** |
| `curCodeDesc` | yes | Account currency (e.g. `"MVR"`, `"USD"`) |
| `benefName` | yes | Counterparty name — `null`, blank or literal `"null"` means none |
| `trxNumber2` | yes | Secondary reference; may be blank |
| `baseAmount` | server-only | Signed amount in MVR. Differs from `foreignAmount` on USD accounts. |
| `absAmount` | server-only | Unsigned MVR amount |
| `descr2` / `descr3` / `remarks` | server-only | Extra reference and narrative lines |
| `trxType` | server-only | Numeric transaction type (e.g. `800` Favara credit, `803` Favara transfer, `205` account-to-account) |
| `imageHash` | server-only | Counterparty image hash, fetchable via `P41` (see [07-profile.md](07-profile.md)) |
> On USD accounts `baseAmount` is the MVR equivalent — always use `foreignAmount` for the amount shown against the account.
---
+31 -18
View File
@@ -7,7 +7,7 @@ Fetch debit card information for the authenticated session.
## Endpoint
```
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos
POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/fetchCardInfos
```
---
@@ -17,7 +17,7 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos
See [README](README.md) for cookie and AJAX header format.
```
Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
Referer: https://faisamobilex-wv-v2.mib.com.mv/debitCards
```
---
@@ -38,15 +38,21 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "101",
"reasonText": "Single Debit card fetch success!",
"total_count": "1",
"pos": "1",
"data": [
{
"cardId": "CARD001",
"maskedCardNumber": "4111 **** **** 1234",
"cardStatus": "A",
"cardType": "D",
"cardTypeDesc": "Debit Card",
"customerId": "C000001",
"phoneNumber": "9600000001",
"cardId": "100000000001",
"maskedCardNumber": "4023XXXXXXXX1234",
"cardStatus": "CHST0",
"cardType": "70",
"contractId": "552",
"cardTypeDesc": "VISA EMV Corporate",
"customerId": "40000001",
"phoneNumber": "009609600001",
"cardHolderName": "MOHAMED ALI"
}
]
@@ -64,9 +70,10 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|---|---|---|
| `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"`) |
| `cardStatus` | `string` | Card status code (`CHST0` = valid, `CHST20` = temporarily blocked by client) |
| `cardType` | `string` | Numeric card type code (e.g. `"70"`) |
| `contractId` | `string` | Card contract ID (not consumed) |
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"VISA EMV Corporate"`) |
| `customerId` | `string` | Customer ID |
| `phoneNumber` | `string` | Registered phone number |
| `cardHolderName` | `string` | Name on card |
@@ -84,14 +91,14 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
## Freeze / Unfreeze Card
```
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/freeze
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/unfreeze
POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/freeze
POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/unfreeze
```
Two endpoints — same shape. Pick by intent. Source: `MibCardsClient.kt:74-103`.
```
Referer: https://faisamobilex-wv.mib.com.mv//debitCards/manage?cardId=<cardId>&dashurl=1
Referer: https://faisamobilex-wv-v2.mib.com.mv/debitCards/manage?cardId=<cardId>
```
### Request Body (form-urlencoded)
@@ -106,16 +113,22 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards/manage?cardId=<cardId>&d
```json
{
"success": true,
"reasonText": "Card frozen successfully",
"currentStatusCode": "F"
"responseCode": "1",
"reasonCode": "502",
"reasonText": "Card Frozen",
"currentStatus": "TEMPRORY BLOCKED BY CLIENT",
"currentStatusCode": "CHST20"
}
```
Unfreeze returns `reasonText: "Card Activated"`, `currentStatus: "VALID CARD"`, `currentStatusCode: "CHST0"`.
| Field | Description |
|---|---|
| `success` | `true` on success |
| `reasonText` | Human-readable message (also returned on failure — show to user) |
| `currentStatusCode` | New card status code after the action (e.g. `"F"` = frozen, `"A"` = active) |
| `currentStatus` | Human-readable status after the action |
| `currentStatusCode` | New card status code (`"CHST20"` = frozen, `"CHST0"` = active) |
---
+1 -1
View File
@@ -7,7 +7,7 @@ Fetch active financing deals. Unlike other data endpoints, this returns an HTML
## Endpoint
```
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
GET https://faisamobilex-wv-v2.mib.com.mv/financing?dashurl=1
```
---
+84 -59
View File
@@ -1,92 +1,117 @@
# Personal Profile
This document covers two unrelated profile-adjacent surfaces:
This document covers two profile-adjacent surfaces:
1. **Personal profile** — an HTML page on the WebView host, scraped for display.
1. **Personal details** — full name, email, mobile number and National ID, shown on the login details screen.
2. **Profile image management** — three encrypted-API endpoints (`P40`/`P41`/`P42`) that fetch, upload, and delete the avatar.
---
## 1. Personal Profile (HTML scrape)
## 1. Personal Details
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping.
The details come from two sources and are fetched once at login, then cached (`MibProfileClient.kt`).
### Endpoint
| Field | Source |
|---|---|
| Full name | `A41` login init response — `fullName` |
| Email | Alias suggestions — type `1` (falls back to `A41` `email`) |
| Mobile number | Alias suggestions — type `0` |
| National ID | Alias suggestions — type `3` |
```
GET https://faisamobilex-wv.mib.com.mv/personalProfile
### Full name — `A41`
The `A41` login init response (see [02-login.md](02-login.md)) carries the user's details at the root:
```json
{
"fullName": "Mohamed Ali",
"email": "user@example.com",
"lastLoginTime": "2026-05-16 15:10:25",
"customerImgHash": "<image hash>",
"roleName": "Consumer Premium"
}
```
### Authentication
Session cookies only — no additional AJAX headers required.
### Alias Suggestions — `getAliasTypes`
```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
POST https://faisamobilex-wv-v2.mib.com.mv/ajaxAlias/getAliasTypes
```
### Response
WebView session cookies and AJAX headers (see [README](README.md)). Empty POST body.
**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>
```
Referer: https://faisamobilex-wv-v2.mib.com.mv/Alias/Alias?dashurl=1
```
Regex:
```kotlin
Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
The suggestions belong to the **currently selected** profile and differ between personal and business profiles. Select the personal profile (`profileType: "0"`) with `P47` first (see [03-accounts.md](03-accounts.md)).
#### Response
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "203",
"reasonText": "Alias Types Search Success.",
"data": [
{ "id": 0, "aliasTypeName": "Mobile Number", "suggestedAlias": "+9609600001" },
{ "id": 1, "aliasTypeName": "E-Mail", "suggestedAlias": "user@example.com" },
{ "id": 3, "aliasTypeName": "National ID", "suggestedAlias": "A000001" }
]
}
```
**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 |
| `id` | `aliasTypeName` | Used as |
|---|---|---|
| `Username:` | `username` | Login username |
| `Email:` | `email` | Registered email address |
| `Mobile no:` | `mobile` | Registered mobile number |
| `Enrolled:` | `enrolled` | Enrollment date or status |
| `0` | Mobile Number | Mobile number (with `+960` prefix) |
| `1` | E-Mail | Email |
| `3` | National ID | ID card number |
Combined with the `fullName` from the `<h5>`:
A type may be missing from `data` when the user has no value for it.
```kotlin
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
)
### Registered Aliases — `ajaxAlias/main`
```
POST https://faisamobilex-wv-v2.mib.com.mv/ajaxAlias/main
```
### Notes
Lists the user's registered Favara aliases. Not consumed by the app.
- 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.
Body: `page=1&search=&searchAccount=&searchAliasType=&sort=AlsId&sortDir=asc&start=1&end=20&includeCount=1`
```json
{
"success": true,
"reasonText": "Favara ID Search Success.",
"data": [
{
"AlsBfy": { "Tp": "MSISDN", "Id": "+9609600001" },
"BfyNm": "Mohamed",
"BfySnm": "Ali",
"CdtrAcct": { "Acct": "90100000000000000", "FinInstnId": "MADVMVMV" },
"AcctHldrId": "A000001",
"AcctHldrIdTp": "IDENTITY_NUMBER",
"AcctHldrTp": "INDIVIDUAL",
"AdrLine": "<address>",
"Ctry": "MDV",
"RegDtTm": "2026-05-12T20:07:44",
"AlsSt": "ACTIVE",
"PhneNb": "+9609600001",
"EmailAdr": "user@example.com"
}
],
"Pg": { "Size": 20, "TtlElms": 1, "TtlPgs": 1, "Nmb": 0 }
}
```
`AlsBfy.Tp` is the alias type, e.g. `MSISDN` or `NATIONAL_ID`.
---
## 2. Profile Image Management
The avatar lives on the **encrypted API** (`faisanet.mib.com.mv`), not the WebView host. Three `sfunc=n` route paths cover fetch, upload, and delete. All three follow the standard encrypted-request format (see [01-encryption.md](01-encryption.md) and [02-login.md](02-login.md)) and include the standard `baseData` fields (`nonce`, `appId`, `sodium`, `routePath`, `xxid`).
The avatar lives on the **encrypted API** (`faisamobilex-smvc-v2.mib.com.mv`), not the WebView host. Three `sfunc=n` route paths cover fetch, upload, and delete. All three follow the standard encrypted-request format (see [01-encryption.md](01-encryption.md) and [02-login.md](02-login.md)) and include the standard `baseData` fields (`nonce`, `appId`, `sodium`, `routePath`, `xxid`).
| `routePath` | Operation | Source |
|---|---|---|
@@ -101,10 +126,10 @@ The same conceptual value — "the hash that identifies a customer's avatar" —
| Source endpoint | Field name |
|---|---|
| `A41` login init (`operatingProfiles[]`) | `customerImage` |
| `A41` login init (root) | `customerImgHash` |
| `C41` registration init (root) | `customerImgHash` |
| `ajaxBeneficiary/main` (contacts list) | `customerImgHash` |
Both are non-empty hash strings that can be passed straight into `P41` as `imageHash`. Treat them as the same value with two names.
Both are non-empty hash strings that can be passed straight into `P41` as `imageHash`. Treat them as the same value with two names. Contact photos use a separate route, `B46` (see [09-contacts.md](09-contacts.md)).
### Fetch — `routePath: P41`
+209 -149
View File
@@ -2,197 +2,244 @@
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
```
All endpoints are on the encrypted API (`sfunc=n`, session key). See [README](README.md) for the request format. The source account must belong to the **currently selected** profile — call `P47` for its profile first (see [03-accounts.md](03-accounts.md)).
---
## Step 1 — Account Lookup
## Step 1 — Account Lookup — `routePath: B40`
The lookup endpoint depends on the format of the input:
One route resolves every supported input:
| Input format | Endpoint | Body field |
| Input format | Example | Resolves to |
|---|---|---|
| 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` |
| Starts with `7`, exactly 13 digits | `7700000000000` | BML / local bank account |
| Starts with `9`, exactly 17 digits | `90100000000000000` | MIB account |
| Starts with `7` or `9`, exactly 7 digits | `9600001` | Favara mobile alias |
| `A` followed by 6 digits | `A000001` | Favara National ID alias |
| Contains `@` | `user@example.com` | Favara email alias |
---
### 1a. IPS Account — BML / local bank (13 digits, starts with `7`)
```
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount
```
Body: `benefAccount=7700000000000`
**Response**:
**Request**:
```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.
> **USD cross-bank accounts**: `getIPSAccount` only succeeds for **MVR** cross-bank accounts. A 13-digit `7…` account that is denominated in USD returns `success: false` here and cannot be resolved via IPS at all. The client hardcodes `currency = "MVR"` for IPS results (`MibTransferClient.kt:135-137`). For USD BML→MIB transfers the user must first save a BML contact (see Notes below).
---
### 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",
"currencyCode": "462"
}
```
- `accountName` may be at root level or inside a `data` object — check both (`MibTransferClient.kt:160-161`)
- `currencyCode` may also be at root level or inside `data` (`MibTransferClient.kt:162-163`). `"462"` = MVR, `"840"` = USD. The client maps this into `MibIpsAccountInfo.currency` ∈ `{"MVR", "USD", ""}` — this is the **MIB→MIB USD detection** fix from commit `16fd909`.
- 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",
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"BfyNm": "Account Holder Name",
"CdtrAcct": {
"Acct": "90100000000000000",
"FinInstnId": "MADVMVMV"
}
"search": "7700000000000",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "B40",
"xxid": "<session xxid>"
}
}
```
- `BfyNm` — beneficiary name (trim whitespace)
- `CdtrAcct.Acct` — resolved account number to use for the transfer
- `CdtrAcct.FinInstnId` — bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML)
### Input Rules
Use `bankNo=2` (MIB) or `3` (BML/local) depending on `FinInstnId`, and the matching transfer endpoint.
- **Mobile aliases** must be the bare 7 digits. `+960 9600001` and `+9609600001` return no match.
- **National ID aliases** need an uppercase `A` — `a000001` returns no match, `A000001` resolves. The client uppercases the prefix before sending (`MibTransferClient.kt`).
- Partial input (e.g. `9600`) returns an empty `data` array.
---
### 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
### Response
**BML account**:
```json
{
"success": true,
"data": [
{
"trxId": "TRX20260516001",
"date": "2026-05-16 15:10:25"
"benefName": "ACCOUNT HOLDER NAME",
"benefAccount": "7700000000000",
"bankCode": "MALBMVMV",
"benefBankName": "Bank of Maldives"
}
]
}
```
**MIB account**:
```json
{
"success": true,
"data": [
{
"benefName": "ACCOUNT HOLDER NAME",
"benefAccount": "90100000000000000",
"bankCode": "MIB",
"benefBankName": "MIB",
"transferCy": "462",
"transferCyDesc": "MVR"
}
]
}
```
**Favara alias** (resolving to an MIB account):
```json
{
"success": true,
"data": [
{
"benefName": "Account Holder Name ",
"benefAccount": "90100000000000000",
"bankCode": "MADVMVMV",
"benefBankName": "Maldives Islamic Bank PLC."
}
]
}
```
| Field | Description |
|---|---|
| `benefName` | Account holder name — trim whitespace |
| `benefAccount` | Resolved account number to use for the transfer |
| `bankCode` | `"MIB"` or a bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML). Normalize `"MIB"` to `MADVMVMV`. |
| `benefBankName` | Bank display name |
| `transferCy` / `transferCyDesc` | Account currency — present for MIB account lookups only |
### Lookup Errors
No match returns `success: true` with an empty `data` array:
```json
{ "success": true, "data": [] }
```
Show "account not found" in that case.
### Currency
| Lookup | Currency |
|---|---|
| MIB account | From `transferCyDesc` (`"MVR"` / `"USD"`) |
| Favara alias → MIB | Not returned — unknown |
| BML / local | `"MVR"` — cross-bank lookups only resolve MVR accounts |
> **USD cross-bank accounts**: a BML account denominated in USD cannot be resolved here. For USD BML→MIB transfers the user must first save a BML contact (see Notes below).
---
## Step 2 — Execute Transfer
Two routes depending on the destination bank:
| `bankNo` | `routePath` | Destination |
|---|---|---|
| `2` | `T50` | MIB internal account |
| `3` | `T51` | BML or other local bank (via Favara) |
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"benefName": "ACCOUNT HOLDER NAME",
"benefNo": "0",
"fromAccountNo": "90101000000001000",
"benefAccountNo": "7700000000000",
"transferCy": "462",
"benefCurrencyCode": "462",
"amount": "100.00",
"bankNo": "3",
"purpose": "",
"otp": "123456",
"otpType": "3",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "T51",
"xxid": "<session xxid>"
}
}
```
| 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"`, `".01"`) |
| `bankNo` | `"2"` = MIB internal, `"3"` = local/BML |
| `purpose` | Transfer remarks; may be empty |
| `otp` | Current TOTP code |
| `otpType` | `"3"` (authenticator OTP) |
### Response — Success
**`T50`** (internal):
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "2011",
"reasonText": "Internal transfer success",
"data": [
{ "date": "2026-05-16 15:10:25", "trxId": "97999863", "availableBalance": "37.41" }
],
"accountBalance": [ ... ]
}
```
**`T51`** (local):
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "2011",
"reasonText": "Local transfer completed successfully via Favara",
"data": [
{ "date": "2026-05-16 15:10:25", "trxId": "817054125" }
]
}
```
| Field | Description |
|---|---|
| `trxId` | Transaction ID |
| `date` | Completion timestamp |
| `availableBalance` | `T50` only — source account balance after the transfer |
| `accountBalance` | `T50` only — refreshed balances for the selected profile (same shape as in [03-accounts.md](03-accounts.md)) |
### Response — Failure
Failures return HTTP `400` with an encrypted body:
```json
{
"success": false,
"reasonText": "Insufficient balance"
"responseCode": "1",
"reasonCode": "213",
"reasonText": "Invalid OTP",
"data": [ { "otpId": "50487016" } ]
}
```
`reasonText` contains the error reason. On HTTP `419`, the session has expired — re-login required.
| `reasonCode` | `reasonText` | Meaning |
|---|---|---|
| `213` | `Invalid OTP` | Wrong or stale OTP |
| `707` | `That OTP is already used!` | The OTP was already accepted for an earlier request |
| `201` | `Transaction currency does not match From Account currency!` | `transferCy` doesn't match the source account |
Show `reasonText` to the user.
### OTP Retry
On `213` or `707` the client waits for the next 30-second TOTP window (plus 1 second for clock skew), generates a new code and resends the transfer **once**. A second failure of any kind is shown to the user (`MibTransferHandler.kt`).
---
## Transfer Type Summary
| Recipient | Lookup endpoint | `bankNo` | Transfer endpoint |
| Recipient | Lookup | `bankNo` | Transfer |
|---|---|---|---|
| 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` |
| MIB (17-digit `9…`) | `B40` | `2` | `T50` |
| BML (13-digit `7…`) | `B40` | `3` | `T51` |
| Favara alias → MIB | `B40` | `2` | `T50` |
| Favara alias → BML | `B40` | `3` | `T51` |
---
@@ -205,23 +252,36 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferLocal
---
## Other Transfer-Screen Routes
Called by the official app when opening the transfer screen. Not consumed by thijooree.
| `routePath` | Request | Returns |
|---|---|---|
| `T63` | — | Currencies with rates (`numericCode`, `code`, `name`, `rate`) |
| `T66` | `currencyCode` | Sell rate for a currency (`sellRate`) |
| `T70` | — | Upload settings (`numFiles`, `fileSize`) |
| `T71` | optional `transferCy` | AML thresholds (`eddAmount`, `cddAmount`, `currencyCode`) |
---
## `MibIpsAccountInfo` (client model)
All three lookups return this unified structure (`MibModels.kt:42-47`):
`B40` lookups are mapped into this structure (`MibModels.kt`):
| Field | Description |
|---|---|
| `accountName` | Account holder name (trimmed) |
| `accountNumber` | Resolved account number |
| `bankId` | Bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML, etc.) |
| `currency` | `"MVR"`, `"USD"`, or `""` (unknown). Populated from `currencyCode` for MIB internal lookups; hardcoded `"MVR"` for IPS lookups; default `""` for alias lookups. |
| `currency` | `"MVR"`, `"USD"`, or `""` (unknown) — see Currency above |
---
## Notes
- **BML → MIB USD transfers** require a saved BML contact first. Because `getIPSAccount` rejects USD accounts (`success: false`), the app cannot validate the BML USD account number directly. The workaround in `TransferFragment.kt` is to call `MibContactsClient.createContact` (see [09-contacts.md](09-contacts.md)) to auto-add the BML account as a beneficiary, then transfer to that beneficiary. Introduced in commit `16fd909`.
- **Session expiry**: HTTP `419` on either lookup or transfer means the session expired. See [README](README.md) for the unified expiry detection rules.
- **BML → MIB USD transfers** require a saved BML contact first. Because USD BML accounts can't be resolved via `B40`, the app cannot validate the account number directly. The workaround in `TransferFragment.kt` is to add the BML account as a beneficiary (see [09-contacts.md](09-contacts.md)), then transfer to that beneficiary.
- **Session expiry**: see [README](README.md) for the unified expiry detection rules.
---
+187 -132
View File
@@ -1,62 +1,58 @@
# Contacts (Beneficiaries)
Manage the user's saved beneficiary list. All endpoints use WebView session auth — see [README](README.md).
Manage the user's saved beneficiary list. All endpoints are on the encrypted API (`sfunc=n`, session key) — see [README](README.md) for the request format.
```
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
```
Contacts belong to the **currently selected** profile — call `P47` for the profile first (see [03-accounts.md](03-accounts.md)).
Every write returns a `beneficiaryVersion` counter that increments on each change.
---
## List Categories
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories
```
Empty POST body.
### Response
## List Contacts — `routePath: B41`
**Request**:
```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" }
]
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"start": 1,
"end": 10,
"benefType": "A",
"search": "",
"includeCount": 1,
"searchCategoryId": 0,
"sortBenef": "",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "B41",
"xxid": "<session xxid>"
}
}
```
| 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) |
| `start` | `1` | First row (`rnum`, 1-based, inclusive) |
| `end` | `10` | Last row (inclusive) |
| `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 |
| `search` | `` | Search query (empty = all) |
| `includeCount` | `1` | Include `total_count` |
| `searchCategoryId` | `0` | Category filter (`0` = all) |
| `sortBenef` | `` | Sort field (empty = default) |
### Pagination
Rows are selected by their 1-based `rnum`, both ends inclusive. The client pages in windows of 10:
| Page | `start` | `end` |
|---|---|---|
| 1 | `1` | `10` |
| 2 | `11` | `20` |
| N | `(N-1)*10 + 1` | `N*10` |
The last row of the final page has `last: "1"`. Stop there, or when total fetched equals `total_count`.
### Beneficiary Types
@@ -71,24 +67,42 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
```json
{
"success": true,
"total_count": "48",
"responseCode": "1",
"reasonCode": "103",
"reasonText": "beneficiary retrieval success",
"total_count": "47",
"pos": "0",
"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",
"benefNickName": "Nickname",
"benefSwiftCode": "MALBMVMV",
"transferCy": "462",
"transferCyDesc": "MVR",
"customerImgHash": "abcd1234hash",
"benefCategoryID": "100002"
"customerImgHash": "<image hash>",
"benefImgHash": "<image hash>",
"benefCategoryID": "100002",
"createdDate": "2025-05-25 10:38:49",
"rnum": "1",
"last": "0"
}
],
"categories": [
{
"id": "100002",
"categoryName": "Friends",
"icon": "f095",
"createdDate": "2023-05-18 14:35:22",
"modifiedDate": "2023-05-19 12:06:05",
"numBenef": "23"
}
]
}
@@ -96,37 +110,51 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
| Field | Consumed | Description |
|---|---|---|
| `benefNo` | yes | Unique beneficiary ID — use for delete |
| `benefNo` | yes | Unique beneficiary ID — use for update and delete |
| `benefName` | yes | Legal/full name; used as fallback when nickname is blank |
| `benefNickName` | yes | User-assigned nickname (prefer over `benefName` for display) |
| `benefAccount` | yes | Account number |
| `benefType` | yes | `L`, `I`, or `S` |
| `bankColor` | yes | Hex color for placeholder avatar background |
| `benefBankName` | yes | Bank display name. Shown as transfer subtitle and contact detail (`MibContactParser.kt:23, 26`). |
| `benefBankName` | yes | Bank display name. Shown as transfer subtitle and contact detail (`MibContactParser.kt`). |
| `bankCode` | yes | Short bank code (e.g. `"BML"`, `"MIB"`) |
| `benefStatus` | yes | Beneficiary status (`"A"` = active) |
| `transferCyDesc` | yes | Currency (e.g. `"MVR"`, `"USD"`) |
| `customerImgHash` | yes | Hash for fetching profile photo. May be missing, blank, or the literal **string** `"null"` — the client filters all three (`MibContactsClient.kt:120`) so downstream code only sees a real hash or `null`. |
| `customerImgHash` | yes | Hash for fetching the contact photo via `B46`. May be missing, blank, or the literal **string** `"null"` — the client filters all three so downstream code only sees a real hash or `null`. |
| `benefCategoryID` | yes | Category ID — `"0"` means uncategorized |
| `benefSwiftCode` | server-only | SWIFT BIC of the beneficiary's bank — present in payload, not read by the app |
| `benefImgHash` | server-only | Photo set on the beneficiary itself; equals `customerImgHash` when set |
| `benefSwiftCode` | server-only | SWIFT BIC — present in payload, not read by the app |
| `benefBankId` | server-only | Numeric bank ID — not consumed |
| `transferCy` | server-only | Currency numeric code (e.g. `"462"`) — only `transferCyDesc` is consumed |
| `rnum` / `last` | server-only | Row number and last-row flag, used for paging |
### Categories
Every `B41` response carries the full category list in `categories` — there is no separate categories call.
| Field | Description |
|---|---|
| `id` | Category ID — use as `searchCategoryId` / `categoryId` |
| `categoryName` | Display name |
| `numBenef` | Number of beneficiaries in this category (string) |
| `icon` | Icon code |
---
## Get Profile Image
## Get Contact Photo — `routePath: B46`
**Request** — route fields:
```json
{ "imageHash": "<customerImgHash>", "routePath": "B46" }
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage
```
Body: `imageHash=<customerImgHash>`
### Response
```json
{
"success": true,
"msg": "image found",
"message": "image found",
"profileImage": "<base64-encoded JPEG>"
}
```
@@ -135,98 +163,125 @@ Body: `imageHash=<customerImgHash>`
---
## Create Contact
## Create Contact — `routePath: B43`
**Request** — route fields:
```json
{
"benefName": "RECIPIENT NAME",
"benefAccount": "7700000000001",
"bankNo": "3",
"nickName": "Nickname",
"transferCy": "462",
"categoryId": "0",
"image": "<base64 JPEG, optional>",
"bankName": "Bank of Maldives PLC",
"bankSwift": "MALBMVMV",
"routePath": "B43"
}
```
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 |
| `benefName` | Full name (from account lookup) |
| `benefAccount` | Beneficiary account number |
| `benefName` | Full name |
| `nickName` | Display nickname |
| `bankNo` | `2` = MIB, `3` = BML/local |
| `transferCy` | Currency numeric code (`"462"` = MVR) |
| `bankNo` | `"2"` = MIB, `"3"` = BML |
| `nickName` | Display nickname — **max 20 characters** |
| `transferCy` | Currency numeric code (`"462"` = MVR, `"840"` = USD) |
| `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 |
| `image` | Base64 JPEG photo. Omit the field when there is no photo. |
| `bankName` | Bank name from the login bank list (`A41` `bankData`) |
| `bankSwift` | Bank BIC from the same list |
### 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.
| `bankNo` | `bankName` | `bankSwift` |
|---|---|---|
| `2` | Maldives Islamic Bank | `MADVMVMV` |
| `3` | Bank of Maldives PLC | `MALBMVMV` |
### Response
```json
{
"success": true,
"data": [
{ "type": "L", "count": "30" },
{ "type": "I", "count": "10" },
{ "type": "S", "count": "2" }
]
"responseCode": "1",
"reasonCode": "102",
"reasonText": "beneficiary creation success",
"data": [ { "benefNo": "1336023", "benefNickName": "Nickname", "customerImgHash": "<image hash>", ... } ],
"beneficiaryVersion": 8
}
```
Gives counts per beneficiary type. Useful for showing tab badges.
### Errors
A nickname over 20 characters is rejected with HTTP `400`:
```json
{ "success": false, "responseCode": "2", "reasonCode": "201", "reasonText": "invalid nick name", "errorKey": "nickName" }
```
---
## Update Contact — `routePath: B52`
Same fields as `B43`, plus:
| Field | Description |
|---|---|
| `benefNo` | Beneficiary to update |
| `imageChanged` | `"1"` when a new `image` is sent, `"0"` to keep the current photo |
**Request** — route fields:
```json
{
"benefName": "RECIPIENT NAME",
"benefAccount": "7700000000001",
"bankNo": "3",
"nickName": "New nickname",
"transferCy": "462",
"categoryId": "100002",
"benefNo": "240301",
"imageChanged": "0",
"bankName": "Bank of Maldives PLC",
"bankSwift": "MALBMVMV",
"routePath": "B52"
}
```
### Response
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "107",
"reasonText": "Beneficiary Updated Successfully",
"data": [ { "benefNo": "240301", "benefNickName": "New nickname", "customerImgHash": "<image hash>", ... } ],
"beneficiaryVersion": 6
}
```
Reload the list with `B41` afterwards to pick up the change.
---
## Delete Contact — `routePath: B48`
**Request** — route fields:
```json
{ "benefNo": "240301", "routePath": "B48" }
```
### Response
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "108",
"reasonText": "Beneficiary deleted Successfully",
"beneficiaryVersion": 7
}
```
---
+2 -2
View File
@@ -9,7 +9,7 @@ Source: `MibActivityHistoryClient.kt`.
## Endpoint
```
POST https://faisamobilex-wv.mib.com.mv/aProfile/getPagedActivityHistory
POST https://faisamobilex-wv-v2.mib.com.mv/aProfile/getPagedActivityHistory
```
---
@@ -21,7 +21,7 @@ WebView session cookies (see [README](README.md)) plus `X-Requested-With: XMLHtt
Unlike most WebView AJAX calls, this endpoint sends **no `Referer`** and **no `Origin`** header.
```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
Cookie: mbmodel=AND-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Linux; Android <ver>; wv) AppleWebKit/537.36 ...
```
+36 -10
View File
@@ -12,8 +12,8 @@ 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 | `https://faisamobilex-smvc-v2.mib.com.mv/index/` | Blowfish + DH session key | Login, accounts, history, transfers, contacts, profile images |
| WebView host | `https://faisamobilex-wv-v2.mib.com.mv` | Session cookies | Cards, financing, personal details, activity history, keep-alive |
---
@@ -22,10 +22,24 @@ MIB uses **two completely separate backends**:
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>
xxid=<session_xxid>&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.
`xxid` is omitted on the key exchanges (`sfunc=r` / `sfunc=i`); `sfunc=i` sends `key2` instead. The request JSON is encrypted with Blowfish (ECB, PKCS5) before sending. The response body is also base64-encoded Blowfish ciphertext — on failures too, which come back as HTTP `400` with an encrypted `success: false` body.
Every `sfunc=n` call is identified by a `routePath` inside the encrypted payload, alongside the standard fields:
```json
{
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random int>",
"routePath": "<route, e.g. A84>",
"xxid": "<session xxid>"
}
```
Route-specific fields are added to the same object. See [02-login.md](02-login.md) for the request headers.
Two keys are used:
@@ -40,17 +54,17 @@ 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`:
After login, the WebView endpoints use cookie-based auth on `faisamobilex-wv-v2.mib.com.mv`:
```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
Cookie: mbmodel=AND-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.
**Cookie notes:**
- `time-tracker=597` is a **hardcoded constant** in the client. Every WebView client (transfers, contacts, cards, activity, etc.) sends the literal value `597` — it is not computed or rotated.
- `time-tracker=597` is a **hardcoded constant** in the client. Every WebView client (cards, financing, activity, etc.) sends the literal value `597` — it is not computed or rotated.
- `mbnonce` is the **unmodified `nonceGenerator` string** from the key-exchange response. It does **not** carry a freshly-computed per-request nonce. The actual per-request nonce (derived via the algorithm in [01-encryption.md](01-encryption.md)) only appears inside the encrypted payloads of `sfunc=n` calls on the encrypted API — WebView endpoints have no nonce field.
### WebView AJAX Headers
@@ -60,7 +74,7 @@ All AJAX `POST` calls also require:
```
X-Requested-With: XMLHttpRequest
Accept: */*
Origin: https://faisamobilex-wv.mib.com.mv
Origin: https://faisamobilex-wv-v2.mib.com.mv
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
```
@@ -79,6 +93,18 @@ A MIB session is considered expired when **either** condition is met (`MibLoginF
On detection the client auto-recovers by re-running the login flow using stored credentials, refreshes `xxid` + `nonceGenerator` in the in-flight payload, and retries the original request once. Callers receive the retried response transparently. If the recovery itself hits expiry again it surfaces a `SessionExpiredException`.
### Keep-Alive
```
POST https://faisamobilex-wv-v2.mib.com.mv/aProfile/keepAlive
```
Empty body, session cookies only. Sent periodically to keep the session open. An expired session returns HTTP `419` with:
```json
{ "success": false, "responseCode": "3", "reasonCode": "41", "reasonText": "Session Expired" }
```
### WebView User-Agent
```
@@ -93,11 +119,11 @@ Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 (KHTML, like Gecko
|---|---|---|
| 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 |
| 3 | [03-accounts.md](03-accounts.md) | Select profile, account balances, account refresh |
| 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) |
| 7 | [07-profile.md](07-profile.md) | Personal details, profile image |
| 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer |
| 9 | [09-contacts.md](09-contacts.md) | Beneficiary management |
| 10 | [10-activity-history.md](10-activity-history.md) | Activity / audit log |
@@ -0,0 +1,5 @@
- upgraded MIB to new v2 API (login, history, cards, contacts, transfers)
- edit MIB contacts (alias, group, photo)
- MIB contact alias limited to 20 characters
- MIB transfers retry once with a new OTP if OTP is invalid or already used
- MIB login now shows ID card, mobile and email in settings