Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2764c6e452
|
||
|
|
740030e061
|
||
|
|
6b9ac5532b
|
||
|
|
46edefe4d7
|
||
|
|
870d3eb0f6
|
||
|
|
7ccd8aa4f5
|
||
|
|
05ff5a829a
|
||
|
|
d4805219f4
|
||
|
|
4c680ad22c
|
@@ -1,3 +1,4 @@
|
||||
.envrc
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
@@ -23,3 +24,4 @@ docs/ooredooapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
.claude/*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>"
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user