Compare commits

...
14 Commits
Author SHA1 Message Date
shihaam 2764c6e452 update mib api docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-23 05:38:33 +05:00
shihaam 740030e061 release 1.0.36
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m16s
2026-10-23 05:30:02 +05:00
shihaam 6b9ac5532b MIB now fetch users info (idcard, phone num, email) to display in settings
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-23 05:27:09 +05:00
shihaam 46edefe4d7 MIB v2 history, cards, contacts, transfers, contact edit 2026-10-23 05:15:43 +05:00
shihaam 870d3eb0f6 MIB v2 login works
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-23 03:09:42 +05:00
shihaam 7ccd8aa4f5 untrack claude 2026-10-23 03:09:26 +05:00
shihaam 05ff5a829a upgrade mib plan
Auto Tag on Version Change / check-version (push) Successful in 8s
2026-10-23 02:26:53 +05:00
shihaam d4805219f4 new version number because fayaz broke build
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 3m34s
2026-10-03 01:50:27 +05:00
shihaam 4c680ad22c remove unused transfer_select_service translation 2026-10-03 01:49:59 +05:00
shihaam 494a42c1a8 build on versionname change
Auto Tag on Version Change / check-version (push) Successful in 7s
Build and Release APK / build (push) Failing after 3m3s
2026-10-03 01:43:25 +05:00
shihaam 9ace8dd724 release v1.0.34
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-03 01:40:43 +05:00
shihaam a3449167db fix build issue applyfahipaycontact 2026-10-03 01:39:19 +05:00
shihaam 1612e21471 Revert "release v1.0.34"
This reverts commit f02b05b180.
2026-10-03 01:37:51 +05:00
shihaam f02b05b180 release v1.0.34
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Failing after 1m27s
2026-10-03 01:33:22 +05:00
45 changed files with 1256 additions and 1006 deletions
+3 -12
View File
@@ -24,20 +24,11 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
BEFORE="${{ github.event.before }}" if git tag -l | grep -q "^v${VERSION}$"; then
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then echo "Tag v${VERSION} already exists, skipping"
BEFORE="HEAD~1"
fi
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
echo "versionCode unchanged (${VERSION_CODE}), skipping"
echo "should_release=false" >> $GITHUB_OUTPUT echo "should_release=false" >> $GITHUB_OUTPUT
elif git tag -l | grep -q "^v${VERSION}$"; then
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
exit 1
else else
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})" echo "New version detected: v${VERSION}"
echo "should_release=true" >> $GITHUB_OUTPUT echo "should_release=true" >> $GITHUB_OUTPUT
fi fi
+2
View File
@@ -1,3 +1,4 @@
.envrc
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
@@ -23,3 +24,4 @@ docs/ooredooapi/tmp
tmp tmp
app/key.jks app/key.jks
.kotlin/* .kotlin/*
.claude/*
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 34 versionCode = 36
versionName = "1.0.33" versionName = "1.0.36"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -11,7 +11,7 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile", "Log in") 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 { class MibActivityHistoryClient {
@@ -35,7 +35,7 @@ class MibActivityHistoryClient {
start: Int, start: Int,
end: Int end: Int
): FetchResult { ): FetchResult {
val cookieHeader = "mbmodel=IOS-1.0; " + val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " + "xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " + "IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " + "mbnonce=${session.nonceGenerator}; " +
@@ -15,7 +15,7 @@ data class MibCardActionResult(
class MibCardsClient { 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" 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() .build()
private fun cookieHeader(session: MibSession) = 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" "mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> { fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
@@ -1,135 +1,83 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibContactsClient { class MibContactsClient(private val flow: MibLoginFlow) {
private val TAG = "MibContactsClient" fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> =
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" fetchContactsAndCategories(session).second
private val client = OkHttpClient.Builder() fun fetchContacts(session: MibSession): List<MibBeneficiary> =
.connectTimeout(30, TimeUnit.SECONDS) fetchContactsAndCategories(session).first
.readTimeout(30, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) = /**
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " + * Pages through B41. Every page also carries the full category list.
"mbnonce=${session.nonceGenerator}; time-tracker=597" * start/end select rows by their 1-based `rnum`, both ends inclusive.
*/
private fun Request.Builder.withSessionHeaders(session: MibSession): Request.Builder = this fun fetchContactsAndCategories(session: MibSession): Pair<List<MibBeneficiary>, List<MibBeneficiaryCategory>> {
.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())
}
}
private fun parseCategories(json: String): List<MibBeneficiaryCategory> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
id = o.getString("id"),
categoryName = o.getString("categoryName"),
numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 0
)
}
} catch (e: Exception) {
emptyList()
}
}
fun fetchContacts(session: MibSession): List<MibBeneficiary> {
val all = mutableListOf<MibBeneficiary>() val all = mutableListOf<MibBeneficiary>()
var page = 1 val seen = mutableSetOf<String>()
val pageSize = 100 var categories = emptyList<MibBeneficiaryCategory>()
val pageSize = 10 // the official app always requests 10-row windows
var start = 1
while (true) { while (true) {
val start = (page - 1) * pageSize + 1 val resp = flow.request(session, "B41") {
val end = page * pageSize put("start", start)
val body = FormBody.Builder() put("end", start + pageSize - 1)
.add("page", page.toString()) put("benefType", "A")
.add("search", "") put("search", "")
.add("searchCategoryId", "0") put("includeCount", 1)
.add("benefType", "A") put("searchCategoryId", 0)
.add("sortBenef", "name") put("sortBenef", "")
.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 (!resp.optBoolean("success")) break
if (all.size >= totalCount || contacts.isEmpty()) break if (start == 1) categories = parseCategories(resp)
page++ 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 all return Pair(all, categories)
} }
private fun parseContacts(json: String): Pair<List<MibBeneficiary>, Int> { private fun parseCategories(resp: JSONObject): List<MibBeneficiaryCategory> {
return try { val arr = resp.optJSONArray("categories") ?: return emptyList()
val obj = JSONObject(json) return (0 until arr.length()).map { i ->
val totalCount = obj.optString("total_count", "0").toIntOrNull() ?: 0 val o = arr.getJSONObject(i)
val arr = obj.getJSONArray("data") MibBeneficiaryCategory(
val contacts = (0 until arr.length()).map { i -> id = o.getString("id"),
val o = arr.getJSONObject(i) categoryName = o.getString("categoryName"),
val hash = o.optString("customerImgHash") numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 0
MibBeneficiary( )
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName")
.ifBlank { o.optString("benefName") },
benefAccount = o.optString("benefAccount"),
benefType = o.optString("benefType"),
bankColor = o.optString("bankColor", "#888888"),
benefBankName = o.optString("benefBankName"),
bankCode = o.optString("bankCode"),
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = hash.takeIf { it.isNotBlank() && it != "null" },
benefCategoryId = o.optString("benefCategoryID", "0")
)
}
Pair(contacts, totalCount)
} catch (e: Exception) {
Pair(emptyList(), 0)
} }
} }
private fun parseContact(o: JSONObject): MibBeneficiary {
val hash = o.optString("customerImgHash")
return MibBeneficiary(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName")
.ifBlank { o.optString("benefName") },
benefAccount = o.optString("benefAccount"),
benefType = o.optString("benefType"),
bankColor = o.optString("bankColor", "#888888"),
benefBankName = o.optString("benefBankName"),
bankCode = o.optString("bankCode"),
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = hash.takeIf { it.isNotBlank() && it != "null" },
benefCategoryId = o.optString("benefCategoryID", "0")
)
}
/** Creates a beneficiary via B43. The server rejects nicknames longer than ~20 chars. */
fun createContact( fun createContact(
session: MibSession, session: MibSession,
benefType: String, // "I" = MIB internal, "L" = local/BML
bankNo: Int, // 2 = MIB, 3 = BML bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String, benefAccount: String,
benefName: String, benefName: String,
@@ -138,82 +86,62 @@ class MibContactsClient {
categoryId: String = "0", categoryId: String = "0",
imageBase64: String = "" imageBase64: String = ""
): Boolean { ): Boolean {
val body = FormBody.Builder() val (bankName, bankSwift) = bankInfo(bankNo)
.add("benefType", benefType) val resp = flow.request(session, "B43") {
.add("imageSet", if (imageBase64.isNotBlank()) "1" else "0") put("benefName", benefName)
.add("benefAccount", benefAccount) put("benefAccount", benefAccount)
.add("benefIban", "") put("bankNo", bankNo.toString())
.add("benefName", benefName) put("nickName", nickName)
.add("nickName", nickName) put("transferCy", transferCy)
.add("transferCy", transferCy) put("categoryId", categoryId)
.add("transferCySwift", "840") if (imageBase64.isNotBlank()) put("image", imageBase64)
.add("benefAddress", "") put("bankName", bankName)
.add("benefCity", "") put("bankSwift", bankSwift)
.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 }
} }
return resp.optBoolean("success")
} }
fun deleteContact(session: MibSession, benefNo: String): Boolean { /** Updates a beneficiary via B52. A blank [imageBase64] keeps the current photo. */
val body = FormBody.Builder().add("benefNo", benefNo).build() fun updateContact(
val request = Request.Builder() session: MibSession,
.url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary") benefNo: String,
.post(body) bankNo: Int, // 2 = MIB, 3 = BML
.withSessionHeaders(session) benefAccount: String,
.build() benefName: String,
return client.newCall(request).execute().use { response -> nickName: String,
val bodyStr = response.body?.string() ?: return@use false transferCy: String = "462",
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false } 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? { fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
val body = FormBody.Builder() val resp = flow.request(session, "B46") { put("imageHash", imageHash) }
.add("imageHash", imageHash) if (!resp.optBoolean("success")) return null
.build() return resp.optString("profileImage").takeIf { it.isNotBlank() }
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
}
}
} }
} }
@@ -9,7 +9,7 @@ import kotlin.math.ceil
class MibFinancingClient { class MibFinancingClient {
private val TAG = "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() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -17,7 +17,7 @@ class MibFinancingClient {
.build() .build()
fun fetchFinancing(session: MibSession): List<MibFinanceDeal> { fun fetchFinancing(session: MibSession): List<MibFinanceDeal> {
val cookieHeader = "mbmodel=IOS-1.0; " + val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " + "xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " + "IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " + "mbnonce=${session.nonceGenerator}; " +
@@ -1,28 +1,9 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build class MibHistoryClient(private val flow: MibLoginFlow) {
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"
/** /**
* 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) * @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
* @return Pair of (transactions, totalCount) * @return Pair of (transactions, totalCount)
*/ */
@@ -33,58 +14,36 @@ class MibHistoryClient {
start: Int = 1, start: Int = 1,
pageSize: Int = 20 pageSize: Int = 20
): Pair<List<Transaction>, Int> { ): Pair<List<Transaction>, Int> {
val body = FormBody.Builder() // A84 uses a 0-based inclusive range: first page is start=0, end=19
.add("accountNo", accountNo) val json = flow.request(session, "A84") {
.add("trxNo", "") put("accountNo", accountNo)
.add("trxType", "0") put("start", start - 1)
.add("sortTrx", "date") put("end", start - 1 + pageSize - 1)
.add("sortDir", "desc") put("includeCount", true)
.add("fromDate", "") put("trxType", 0)
.add("toDate", "") put("sortTrx", "date")
.add("start", start.toString()) put("sortDir", "desc")
.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) }
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)
val transactions = (0 until data.length()).map { i ->
val item = data.getJSONObject(i)
Transaction(
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
},
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
accountNumber = accountNo,
accountDisplayName = accountDisplayName,
source = "MIB"
)
}
Pair(transactions, total)
} }
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)
val transactions = (0 until data.length()).map { i ->
val item = data.getJSONObject(i)
Transaction(
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
},
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
accountNumber = accountNo,
accountDisplayName = accountDisplayName,
source = "MIB"
)
}
return Pair(transactions, total)
} }
} }
@@ -7,6 +7,10 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest 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.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs import kotlin.math.abs
@@ -16,7 +20,7 @@ class SessionExpiredException : Exception("MIB session expired")
class MibLoginFlow(private val credentialStore: CredentialStore) { 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. */ /** The active session after a successful login, usable for subsequent WebView requests. */
var lastSession: MibSession? = null var lastSession: MibSession? = null
@@ -26,6 +30,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var lastProfiles: List<MibProfile> = emptyList() var lastProfiles: List<MibProfile> = emptyList()
private set 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. */ /** Called after automatic session recovery so callers can update their session reference. */
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
@@ -42,8 +52,9 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.addNetworkInterceptor { chain -> .addNetworkInterceptor { chain ->
val req = chain.request().newBuilder() val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0") .header("User-Agent", "android/2.0")
.header("Accept", "application/json") .header("Accept", "application/json")
.header("X-Request-ID", generateRequestId())
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") .header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.build() .build()
chain.proceed(req) chain.proceed(req)
@@ -144,6 +155,8 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
lastFullName = loginResp.optString("fullName").takeIf { it != "null" } ?: ""
lastEmail = loginResp.optString("email").takeIf { it != "null" } ?: ""
lastSession = session2 lastSession = session2
lastProfiles = profiles // keep ALL profiles so settings can show them all 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) 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 * Activates [profile] server-side via P47, setting the session role context so that
* subsequent WebView requests run under that profile. * subsequent WebView requests run under that profile.
@@ -424,6 +444,16 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
return sha256Upper(clientSalt + h2) 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 { companion object {
/** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */ /** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */
fun hashPassword(password: String): String = fun hashPassword(password: String): String =
@@ -35,7 +35,8 @@ data class MibTransferResult(
val success: Boolean, val success: Boolean,
val trxId: String = "", val trxId: String = "",
val date: String = "", val date: String = "",
val errorMessage: String = "" val errorMessage: String = "",
val reasonCode: String = ""
) )
@@ -1,50 +1,87 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.util.CredentialStore
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
data class MibPersonalProfile( data class MibPersonalProfile(
val fullName: String, val fullName: String,
val username: String,
val email: String, val email: String,
val mobile: 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() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .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? { fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + val personal = flow.lastProfiles.firstOrNull { it.profileType == "0" }
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" if (personal != null) flow.switchProfile(session, personal)
val request = Request.Builder() val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile") .url("$BASE_WV_URL/ajaxAlias/getAliasTypes")
.get() .post(FormBody.Builder().build())
.header("Cookie", cookieHeader) .header("Cookie", "mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}")
.build() .header(
return try { "User-Agent",
val resp = client.newCall(request).execute() "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"
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
) )
} catch (_: Exception) { null } .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 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 MibLookupException(message: String) : Exception(message)
class MibTransferClient { class MibTransferClient(private val flow: MibLoginFlow) {
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")
/** /**
* Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal). * Execute a transfer. bankNo=2 → MIB internal (T50), bankNo=3 → local/BML via Favara (T51).
* currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank. * currencyCode: "462"=MVR, "840"=USD.
*/ */
fun transfer( fun transfer(
session: MibSession, session: MibSession,
@@ -49,157 +19,79 @@ class MibTransferClient {
purpose: String, purpose: String,
otp: String otp: String
): MibTransferResult { ): MibTransferResult {
val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal" val json = flow.request(session, if (bankNo == 2) "T50" else "T51") {
val body = FormBody.Builder() put("benefName", benefName)
.add("benefName", benefName) put("benefNo", "0")
.add("benefNo", "0") put("fromAccountNo", fromAccount)
.add("fromAccountNo", fromAccount) put("benefAccountNo", toAccount)
.add("benefAccountNo", toAccount) put("transferCy", currencyCode)
.add("transferCy", currencyCode) put("benefCurrencyCode", currencyCode)
.add("benefCurrencyCode", currencyCode) put("amount", amount)
.add("amount", amount) put("bankNo", bankNo.toString())
.add("bankNo", bankNo.toString()) put("purpose", purpose)
.add("purpose", purpose.ifBlank { "-" }) put("otp", otp)
.add("otp", otp) put("otpType", "3")
.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(
success = false,
errorMessage = json?.optString("reasonText")?.ifBlank { null } ?: "Transfer failed"
)
} else {
val data = json.optJSONArray("data")?.optJSONObject(0)
MibTransferResult(
success = true,
trxId = data?.optString("trxId") ?: "",
date = data?.optString("date") ?: ""
)
}
} }
if (!json.optBoolean("success")) {
return MibTransferResult(
success = false,
errorMessage = json.optString("reasonText").ifBlank { "Transfer failed" },
reasonCode = json.optString("reasonCode")
)
}
val data = json.optJSONArray("data")?.optJSONObject(0)
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) * - 13 digits starting with 7 → BML / local bank account
* - 17 digits starting with 9 → MIB internal account name lookup * - 17 digits starting with 9 → MIB account
* - 7 digits starting with 7 or 9, * - 7 digits starting with 7 or 9,
* A + 6 digits, * 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 { fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
val trimmed = input.trim() // B40 only resolves Favara IDs with an uppercase "A" ("a239225" fails, "A239225" works)
return when { val trimmed = input.trim().let { if (it.matches(Regex("^a\\d{6}$"))) it.uppercase() else it }
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed) val supported = trimmed.matches(Regex("^7\\d{12}$")) ||
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed) trimmed.matches(Regex("^9\\d{16}$")) ||
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed) trimmed.matches(Regex("^[79]\\d{6}$")) ||
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed) trimmed.matches(Regex("^[Aa]\\d{6}$")) ||
trimmed.contains("@") -> lookupAlias(session, trimmed) trimmed.contains("@")
else -> throw MibLookupException("Unrecognized account number format") if (!supported) throw MibLookupException("Unrecognized account number format")
}
}
/** BML / local bank: 15-digit account starting with 7. */ val json = flow.request(session, "B40") { put("search", trimmed) }
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo { val match = json.optJSONArray("data")?.optJSONObject(0)
val body = FormBody.Builder().add("benefAccount", accountNumber).build() if (!json.optBoolean("success") || match == null) {
val request = Request.Builder() throw MibLookupException(
.url("$BASE_WV_URL/AjaxAlias/getIPSAccount") json.optString("reasonText").trim().ifBlank { "Account not found" }
.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})"
)
}
MibIpsAccountInfo(
accountName = json.optString("accountName").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"
) )
} }
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 = bankId,
currency = currency
)
} }
/** MIB internal: 17-digit account starting with 9. */ companion object {
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo { /** reasonCode for a wrong OTP, e.g. one from the previous window. */
val body = FormBody.Builder().add("accountNo", accountNumber).build() const val REASON_INVALID_OTP = "213"
val request = Request.Builder() /** reasonCode for an OTP the server has already accepted once. */
.url("$BASE_WV_URL/ajaxBeneficiary/getAccountName") const val REASON_OTP_ALREADY_USED = "707"
.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
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")
)
}
} }
} }
@@ -294,7 +294,7 @@ class AccountHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { 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 bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap) ContactImageCache.save(requireContext(), hash, bitmap)
@@ -18,11 +18,13 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment 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 sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlContactsClient import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlValidateClient 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.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
@@ -67,6 +70,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedCategoryId: String = "0" private var selectedCategoryId: String = "0"
private var categories: List<BankContactCategory> = emptyList() 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? -> private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
encodeImage(uri) encodeImage(uri)
@@ -90,7 +96,49 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (selectedDest?.isBml == false) setupCategoryDropdown() 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() { private fun applyPrefillArgs() {
@@ -201,6 +249,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE
binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE
if (isMib) setupCategoryDropdown() 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() { 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) // 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.anyMibSession() ?: return null val mibSess = app.anyMibSession() ?: return null
val mibFlow = app.anyMibFlow() ?: return null
return try { return try {
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient(mibFlow).lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = "DOT", trnType = "DOT",
validationType = "MIB", validationType = "MIB",
@@ -328,8 +389,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val profile = dest.mibProfile ?: return null val profile = dest.mibProfile ?: return null
val mibResult = try { val mibResult = try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile) val flow = app.mibFlowFor(loginId)
val info = MibTransferClient().lookup(mibSess, input) flow.switchProfile(mibSess, profile)
val info = MibTransferClient(flow).lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
validationType = "MIB", validationType = "MIB",
@@ -398,7 +460,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.etCurrency.setText(validation.currency) binding.etCurrency.setText(validation.currency)
binding.btnClearResult.setOnClickListener { clearLookupResult() } binding.btnClearResult.setOnClickListener { clearLookupResult() }
binding.btnSave.isEnabled = true updateSaveState()
} }
private fun clearLookupResult() { private fun clearLookupResult() {
@@ -438,6 +500,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun setupSaveButton() { private fun setupSaveButton() {
binding.btnSave.setOnClickListener { performSave() } binding.btnSave.setOnClickListener { performSave() }
binding.etAlias.addTextChangedListener(afterTextChanged = { updateSaveState() })
} }
private fun performSave() { private fun performSave() {
@@ -447,6 +510,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = "Alias is required" binding.tilAlias.error = "Alias is required"
return return
} }
if (!dest.isBml && alias.length > MIB_MAX_ALIAS) {
updateSaveState()
return
}
binding.tilAlias.error = null binding.tilAlias.error = null
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -454,11 +521,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { 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) { if (success) {
Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show() 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() dismiss()
} else { } else {
binding.btnSave.isEnabled = true binding.btnSave.isEnabled = true
@@ -495,15 +567,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462" val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val isMibInternal = account.matches(Regex("^9\\d{16}$")) val isMibInternal = account.matches(Regex("^9\\d{16}$"))
val benefType = if (isMibInternal) "I" else "L"
val bankNo = if (isMibInternal) 2 else 3 val bankNo = if (isMibInternal) 2 else 3
val name = bmlLookup?.name ?: "" val name = bmlLookup?.name ?: ""
return try { return try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact( MibContactsClient(app.mibFlowFor(loginId)).createContact(
session = mibSess, session = mibSess,
benefType = benefType,
bankNo = bankNo, bankNo = bankNo,
benefAccount = account, benefAccount = account,
benefName = name, benefName = name,
@@ -515,6 +585,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} catch (_: Exception) { false } } 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 { private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt() val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY } val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
@@ -532,35 +629,6 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return bm 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _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 // 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" 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_BML_PROFILE_ID = "bml_profile_id"
private const val ARG_ACCOUNT_NUMBER = "account_number" private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_RECIPIENT_NAME = "recipient_name" private const val ARG_RECIPIENT_NAME = "recipient_name"
private const val ARG_CURRENCY = "currency" 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( fun newInstance(
bmlProfileId: String? = null, bmlProfileId: String? = null,
@@ -77,7 +77,15 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank)) activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
} }
binding.btnEdit.setOnClickListener { binding.btnEdit.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() // 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 { binding.btnDelete.setOnClickListener {
val parent = contactsFragment val parent = contactsFragment
@@ -356,7 +356,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val base64 = if (hash in profileImageHashes) { val base64 = if (hash in profileImageHashes) {
app.anyMibFlow()?.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -143,6 +143,8 @@ class ContactsFragment : Fragment() {
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh() (activity as? HomeActivity)?.triggerRefresh()
// triggerRefresh() only covers accounts; reload contacts from the banks too
(activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.isRefreshing = false binding.swipeRefresh.isRefreshing = false
} }
@@ -257,7 +259,7 @@ class ContactsFragment : Fragment() {
return return
} }
val sess = session ?: return val sess = session ?: return
val client = MibContactsClient() val client = MibContactsClient(app.anyMibFlow() ?: return)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -30,8 +30,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient 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.MibContactsClient
import sh.sar.basedbank.api.mib.MibCardsClient import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient 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.MibProfile
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
@@ -428,10 +431,10 @@ class HomeActivity : AppCompatActivity() {
for (session in sessions) { for (session in sessions) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { 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" "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder() 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()) .post(ByteArray(0).toRequestBody())
.header("Cookie", cookieHeader) .header("Cookie", cookieHeader)
.header("User-Agent", "okhttp/4.11.0") .header("User-Agent", "okhttp/4.11.0")
@@ -819,6 +822,8 @@ fun applyNavLabelVisibility() {
app.mibProfilesMap[loginId] = flow.lastProfiles app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles) 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 accounts
} catch (e: java.io.IOException) { } catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET") refreshErrors.add("NO_INTERNET")
@@ -1210,10 +1215,10 @@ fun applyNavLabelVisibility() {
} }
} }
private fun refreshBmlContacts(app: BasedBankApp) { private fun refreshBmlContacts(app: BasedBankApp): Job? {
if (app.bmlSessions.isEmpty()) return if (app.bmlSessions.isEmpty()) return null
val store = CredentialStore(this) val store = CredentialStore(this)
lifecycleScope.launch { return lifecycleScope.launch {
try { try {
val allBmlContacts = withContext(Dispatchers.IO) { val allBmlContacts = withContext(Dispatchers.IO) {
store.getBmlLoginIds().flatMap { loginId -> 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 app = application as BasedBankApp
val store = sh.sar.basedbank.util.CredentialStore(this) val store = sh.sar.basedbank.util.CredentialStore(this)
// Populate ViewModel from cache immediately if empty // Populate ViewModel from cache immediately if empty
@@ -1249,16 +1255,24 @@ fun applyNavLabelVisibility() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
} }
// Refresh all banks in background // Refresh all banks in background
val jobs = mutableListOf<Job>()
for ((loginId, session) in app.mibSessions) { for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() 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) { private fun refreshFahipayContacts(session: FahipaySession): Job {
lifecycleScope.launch { return lifecycleScope.launch {
try { try {
val groups = withContext(Dispatchers.IO) { val groups = withContext(Dispatchers.IO) {
FahipayContactsClient().fetchContacts(session) FahipayContactsClient().fetchContacts(session)
@@ -1288,11 +1302,11 @@ fun applyNavLabelVisibility() {
return result return result
} }
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) { private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>): Job? {
if (profiles.isEmpty()) return if (profiles.isEmpty()) return null
val flow = (application as BasedBankApp).mibFlowFor(loginId) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient() val contactsClient = MibContactsClient(flow)
lifecycleScope.launch { return lifecycleScope.launch {
try { try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) { val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>() val seenContacts = mutableSetOf<String>()
@@ -1302,10 +1316,12 @@ fun applyNavLabelVisibility() {
for (profile in profiles) { for (profile in profiles) {
try { try {
flow.switchProfile(session, profile) 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) if (seenCategories.add(cat.id)) categories.add(cat)
} }
for (contact in contactsClient.fetchContacts(session)) { for (contact in profileContacts) {
if (seenContacts.add(contact.benefNo)) if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId)) contacts.add(contact.copy(profileId = profile.profileId))
} }
@@ -353,15 +353,16 @@ class OtpFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId) val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) { val profile = withContext(Dispatchers.IO) {
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null } try { MibProfileClient(flow).fetchPersonalProfile(session) } catch (_: Exception) { null }
} }
if (profile != null) { if (profile != null) {
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile( store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = loginId,
email = profile.email, email = profile.email,
mobile = profile.mobile, mobile = profile.mobile,
enrolled = profile.enrolled enrolled = "",
idCard = profile.idCard
)) ))
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId } val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
@@ -668,10 +668,13 @@ class SettingsLoginsFragment : Fragment() {
scroll.addView(container) scroll.addView(container)
// Account info lines // Account info lines
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
listOfNotNull( listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" }, 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?.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)}: $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 -> ).forEach { line ->
container.addView(TextView(ctx).apply { container.addView(TextView(ctx).apply {
text = line text = line
@@ -1126,7 +1126,7 @@ class TransferFragment : Fragment() {
binding.etTo.setOnItemClickListener { _, _, position, _ -> binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener val contact = adapter.getContact(position) ?: return@setOnItemClickListener
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) { if (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
return@setOnItemClickListener return@setOnItemClickListener
} }
prefillToDirectly( prefillToDirectly(
@@ -302,7 +302,7 @@ class TransferHistoryFragment : Fragment() {
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) { for (state in states) {
try { try {
val (list, total) = MibHistoryClient().fetchHistory( val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session, session = session,
accountNo = state.account.accountNumber, accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
@@ -382,7 +382,7 @@ class TransferHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { 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 bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap) ContactImageCache.save(requireContext(), hash, bitmap)
@@ -250,7 +250,7 @@ class TransferReceiptFragment : Fragment() {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -8,6 +8,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo 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.MibLookupException
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibTransferClient import sh.sar.basedbank.api.mib.MibTransferClient
@@ -40,6 +41,10 @@ class MibTransferHandler(
val session: MibSession? val session: MibSession?
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession() 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. */ /** Any logged-in MIB session, ignoring which source is selected. */
val anySession: MibSession? get() = app.anyMibSession() val anySession: MibSession? get() = app.anyMibSession()
@@ -54,8 +59,9 @@ class MibTransferHandler(
*/ */
fun lookupDestination(accountNumber: String): LookupOutcome { fun lookupDestination(accountNumber: String): LookupOutcome {
val sess = session ?: return LookupOutcome(null, notFound()) val sess = session ?: return LookupOutcome(null, notFound())
val flow = flow ?: return LookupOutcome(null, notFound())
return try { return try {
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null) LookupOutcome(MibTransferClient(flow).lookup(sess, accountNumber), null)
} catch (e: MibLookupException) { } catch (e: MibLookupException) {
LookupOutcome(null, e.message) LookupOutcome(null, e.message)
} catch (_: Exception) { } catch (_: Exception) {
@@ -69,7 +75,8 @@ class MibTransferHandler(
*/ */
fun lookupCurrency(accountNumber: String): String { fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return "" 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) { "" } catch (_: Exception) { "" }
} }
@@ -88,7 +95,7 @@ class MibTransferHandler(
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash) app.anyMibFlow()?.fetchProfileImage(session, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(session, hash) app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(session, hash)
} }
if (base64 == null) null else { if (base64 == null) null else {
val bytes = Base64.decode(base64, Base64.DEFAULT) 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 * 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 * TOTP. If MIB rejects the OTP as invalid or already used, waits for the next OTP window and
* "CONNECTIVITY" when the network was unreachable. * 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( fun doTransfer(
src: BankAccount, src: BankAccount,
@@ -114,8 +122,7 @@ class MibTransferHandler(
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null) val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val loginId = src.loginTag.removePrefix("mib_") val loginId = src.loginTag.removePrefix("mib_")
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed val otpSeed = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462" val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR" val currency = if (src.currencyName == "USD") "USD" else "MVR"
@@ -145,17 +152,26 @@ class MibTransferHandler(
val profile = profiles.firstOrNull { it.profileId == src.profileId } val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
} }
val result = MibTransferClient().transfer( val client = MibTransferClient(app.mibFlowFor(loginId))
session = sess, val send = { otp: String ->
fromAccount = src.accountNumber, client.transfer(
toAccount = destAccount, session = sess,
amount = amount, fromAccount = src.accountNumber,
currencyCode = currencyCode, toAccount = destAccount,
benefName = destName.ifBlank { "Recipient" }, amount = amount,
bankNo = bankNo, currencyCode = currencyCode,
purpose = remarks, benefName = destName.ifBlank { "Recipient" },
otp = otp bankNo = bankNo,
) 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) { if (result.success) {
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
bank = "MIB", bank = "MIB",
@@ -181,4 +197,13 @@ class MibTransferHandler(
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null) 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) store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
flow.lastSession?.let { s -> flow.lastSession?.let { s ->
val profile = MibProfileClient().fetchPersonalProfile(s) try { MibProfileClient(flow).cacheIfMissing(store, loginId, s) } catch (_: Exception) { }
if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile(
fullName = profile.fullName,
username = profile.username,
email = profile.email,
mobile = profile.mobile,
enrolled = profile.enrolled
)
)
} }
} }
store.saveMibProfiles(loginId, flow.lastProfiles) store.saveMibProfiles(loginId, flow.lastProfiles)
@@ -27,14 +27,18 @@ object ContactManager {
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean { private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false val sess = app.anyMibSession() ?: return false
return try { return try {
var flow = app.anyMibFlow() ?: return false
if (contact.profileId.isNotBlank()) { if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) -> .firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it } profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null) } ?: (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 } } catch (_: Exception) { false }
} }
} }
@@ -669,7 +669,8 @@ class CredentialStore(context: Context) {
val username: String, val username: String,
val email: String, val email: String,
val mobile: 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( data class BmlUserProfile(
@@ -736,6 +737,7 @@ class CredentialStore(context: Context) {
put("email", p.email) put("email", p.email)
put("mobile", p.mobile) put("mobile", p.mobile)
put("enrolled", p.enrolled) put("enrolled", p.enrolled)
if (p.idCard != null) put("idCard", p.idCard)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
@@ -752,7 +754,8 @@ class CredentialStore(context: Context) {
username = o.optString("username"), username = o.optString("username"),
email = o.optString("email"), email = o.optString("email"),
mobile = o.optString("mobile"), 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 } } catch (_: Exception) { null }
} }
@@ -127,7 +127,7 @@ class HistoryFetcher(private val account: BankAccount) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId } val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory( val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session, session = session,
accountNo = account.accountNumber, accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
@@ -18,6 +18,12 @@ object Totp {
return otp.toString().padStart(digits, '0') 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 { private fun hotp(key: ByteArray, counter: Long, digits: Int): Int {
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() } val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
val mac = Mac.getInstance("HmacSHA1") val mac = Mac.getInstance("HmacSHA1")
@@ -12,6 +12,7 @@
android:padding="16dp"> android:padding="16dp">
<TextView <TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
+2 -1
View File
@@ -261,7 +261,6 @@
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string> <string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string> <string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string> <string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
<string name="transfer_select_service">ޚިދުމަތް އިޚްތިޔާރުކުރޭ</string>
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string> <string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string> <string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string> <string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
@@ -331,6 +330,8 @@
<!-- Contact expand/delete --> <!-- Contact expand/delete -->
<string name="contact_edit">އުނިއިތުރުގެނޭ</string> <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">ފުހެލާ</string>
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string> <string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string> <string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
+2
View File
@@ -341,6 +341,8 @@
<!-- Contact expand/delete --> <!-- Contact expand/delete -->
<string name="contact_edit">Edit</string> <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">Delete</string>
<string name="contact_delete_title">Delete Contact</string> <string name="contact_delete_title">Delete Contact</string>
<string name="contact_delete_message">Remove %s from your contacts?</string> <string name="contact_delete_message">Remove %s from your contacts?</string>
+11 -1
View File
@@ -57,6 +57,16 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
## Step-by-Step Reference ## 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` ### [0] Initial Key Exchange — `sfunc=r`
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` **Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
@@ -67,7 +77,7 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
"sfunc": "r", "sfunc": "r",
"data": { "data": {
"cmod": "<G^A mod P as decimal string>", "cmod": "<G^A mod P as decimal string>",
"appId": "IOS17.2-<15 random alphanumeric chars>", "appId": "AND34-<15 random alphanumeric chars>",
"routePath": "S40", "routePath": "S40",
"sodium": "<random int in [1_000_000, 16_000_000)>", "sodium": "<random int in [1_000_000, 16_000_000)>",
"xxid": "<random 40-bit int as string>" "xxid": "<random 40-bit int as string>"
+38
View File
@@ -44,6 +44,44 @@ To switch between profiles (personal ↔ business), call `P47` again with the ot
--- ---
## Refresh Accounts — `sfunc=n`, `routePath: A80`
**Key**: session key
Re-fetches the account list and balances for the **currently selected** profile without re-selecting it. No route-specific fields.
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "A80",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "101",
"reasonText": "Accounts retrieval success!",
"data": [ ... ],
"beneficiaryVersion": 0,
"menuVersion": "<hash>"
}
```
`data` has the same shape as the `accountBalance` array below. Some responses carry `responseCode: "3"`, `reasonCode: "90"`, `reasonText: "unknown error"` with `success: true` and a populated `data` array — treat `success` as authoritative.
---
## Profiles (from `A41` response) ## Profiles (from `A41` response)
The `A41` login init response includes `operatingProfiles`: The `A41` login init response includes `operatingProfiles`:
+74 -50
View File
@@ -1,41 +1,46 @@
# Transaction History # 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 | | Field | Example | Description |
|---|---|---| |---|---|---|
| `accountNo` | `90101000000001000` | Account number to fetch history for | | `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) | | `trxType` | `0` | Transaction type filter (`0` = all) |
| `sortTrx` | `date` | Sort field | | `sortTrx` | `date` | Sort field |
| `sortDir` | `desc` | Sort direction | | `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 ### Pagination
@@ -43,9 +48,9 @@ Page size is 20. Compute `start`/`end` per page:
| Page | `start` | `end` | | Page | `start` | `end` |
|---|---|---| |---|---|---|
| 1 | `1` | `20` | | 1 | `0` | `19` |
| 2 | `21` | `40` | | 2 | `20` | `39` |
| N | `(N-1)*20 + 1` | `N*20` | | N | `(N-1)*20` | `N*20 - 1` |
Stop when total fetched equals `total_count` or `data` is empty. 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 ```json
{ {
"success": true, "success": true,
"responseCode": "1",
"reasonCode": "103",
"reasonText": "Trx History Retrieval Success!",
"total_count": "87", "total_count": "87",
"pos": "0",
"data": [ "data": [
{ {
"trxNumber": "TXN20260516001", "accountNo": "90101000000001000",
"trxDate": "2026-05-16", "curCode": "462",
"descr1": "Transfer Debit",
"baseAmount": "-500.00",
"curCodeDesc": "MVR", "curCodeDesc": "MVR",
"benefName": "Mohamed Ali", "trxNumber": "1-158043140-93434633-1",
"trxNumber2": "FT20260516001" "trxNumber2": "93434633",
}, "trxDate": "2026-05-16 15:10:25",
{ "trxValDate": "2026-05-16 00:00:00",
"trxNumber": "TXN20260515001", "absAmount": "500",
"trxDate": "2026-05-15", "baseAmount": "-500",
"descr1": "Transfer Credit", "foreignAmount": "-500",
"baseAmount": "1000.00", "descr1": "Favara Transfer Web",
"curCodeDesc": "MVR", "descr2": "<reference>",
"benefName": "", "descr3": "<details>",
"trxNumber2": "" "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 | | `success` | `bool` | `true` on success |
| `total_count` | `string` | Total transaction count — parse to `int` | | `total_count` | `string` | Total transaction count — parse to `int` |
| `pos` | `string` | Echo of the requested start row |
| `data` | `array` | Transactions for this page | | `data` | `array` | Transactions for this page |
### Transaction Object ### Transaction Object
| Field | Type | Description | | Field | Consumed | Description |
|---|---|---| |---|---|---|
| `trxNumber` | `string` | Unique transaction ID | | `trxNumber` | yes | Unique transaction ID |
| `trxDate` | `string` | Transaction date (`YYYY-MM-DD`) | | `trxDate` | yes | Transaction timestamp (`yyyy-MM-dd HH:mm:ss`) |
| `descr1` | `string` | Transaction description — trim whitespace | | `descr1` | yes | Transaction description — trim whitespace |
| `baseAmount` | `string` | Decimal string — **negative = debit, positive = credit** | | `foreignAmount` | yes | Amount in the **account's** currency — **negative = debit, positive = credit** |
| `curCodeDesc` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) | | `curCodeDesc` | yes | Account currency (e.g. `"MVR"`, `"USD"`) |
| `benefName` | `string` | Counterparty name — blank or literal `"null"` means none | | `benefName` | yes | Counterparty name — `null`, blank or literal `"null"` means none |
| `trxNumber2` | `string` | Secondary reference; may be blank | | `trxNumber2` | yes | Secondary reference; may be blank |
| `baseAmount` | server-only | Signed amount in MVR. Differs from `foreignAmount` on USD accounts. |
| `absAmount` | server-only | Unsigned MVR amount |
| `descr2` / `descr3` / `remarks` | server-only | Extra reference and narrative lines |
| `trxType` | server-only | Numeric transaction type (e.g. `800` Favara credit, `803` Favara transfer, `205` account-to-account) |
| `imageHash` | server-only | Counterparty image hash, fetchable via `P41` (see [07-profile.md](07-profile.md)) |
> On USD accounts `baseAmount` is the MVR equivalent — always use `foreignAmount` for the amount shown against the account.
--- ---
+31 -18
View File
@@ -7,7 +7,7 @@ Fetch debit card information for the authenticated session.
## Endpoint ## 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. 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 ```json
{ {
"success": true, "success": true,
"responseCode": "1",
"reasonCode": "101",
"reasonText": "Single Debit card fetch success!",
"total_count": "1",
"pos": "1",
"data": [ "data": [
{ {
"cardId": "CARD001", "cardId": "100000000001",
"maskedCardNumber": "4111 **** **** 1234", "maskedCardNumber": "4023XXXXXXXX1234",
"cardStatus": "A", "cardStatus": "CHST0",
"cardType": "D", "cardType": "70",
"cardTypeDesc": "Debit Card", "contractId": "552",
"customerId": "C000001", "cardTypeDesc": "VISA EMV Corporate",
"phoneNumber": "9600000001", "customerId": "40000001",
"phoneNumber": "009609600001",
"cardHolderName": "MOHAMED ALI" "cardHolderName": "MOHAMED ALI"
} }
] ]
@@ -64,9 +70,10 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|---|---|---| |---|---|---|
| `cardId` | `string` | Internal card identifier | | `cardId` | `string` | Internal card identifier |
| `maskedCardNumber` | `string` | Partially masked card number for display | | `maskedCardNumber` | `string` | Partially masked card number for display |
| `cardStatus` | `string` | Card status (`A` = Active) | | `cardStatus` | `string` | Card status code (`CHST0` = valid, `CHST20` = temporarily blocked by client) |
| `cardType` | `string` | Card type code (e.g. `D` = Debit) | | `cardType` | `string` | Numeric card type code (e.g. `"70"`) |
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"Debit Card"`) | | `contractId` | `string` | Card contract ID (not consumed) |
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"VISA EMV Corporate"`) |
| `customerId` | `string` | Customer ID | | `customerId` | `string` | Customer ID |
| `phoneNumber` | `string` | Registered phone number | | `phoneNumber` | `string` | Registered phone number |
| `cardHolderName` | `string` | Name on card | | `cardHolderName` | `string` | Name on card |
@@ -84,14 +91,14 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
## Freeze / Unfreeze Card ## Freeze / Unfreeze Card
``` ```
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/freeze POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/freeze
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/unfreeze POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/unfreeze
``` ```
Two endpoints — same shape. Pick by intent. Source: `MibCardsClient.kt:74-103`. 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) ### Request Body (form-urlencoded)
@@ -106,16 +113,22 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards/manage?cardId=<cardId>&d
```json ```json
{ {
"success": true, "success": true,
"reasonText": "Card frozen successfully", "responseCode": "1",
"currentStatusCode": "F" "reasonCode": "502",
"reasonText": "Card Frozen",
"currentStatus": "TEMPRORY BLOCKED BY CLIENT",
"currentStatusCode": "CHST20"
} }
``` ```
Unfreeze returns `reasonText: "Card Activated"`, `currentStatus: "VALID CARD"`, `currentStatusCode: "CHST0"`.
| Field | Description | | Field | Description |
|---|---| |---|---|
| `success` | `true` on success | | `success` | `true` on success |
| `reasonText` | Human-readable message (also returned on failure — show to user) | | `reasonText` | Human-readable message (also returned on failure — show to user) |
| `currentStatusCode` | New card status code after the action (e.g. `"F"` = frozen, `"A"` = active) | | `currentStatus` | Human-readable status after the action |
| `currentStatusCode` | New card status code (`"CHST20"` = frozen, `"CHST0"` = active) |
--- ---
+1 -1
View File
@@ -7,7 +7,7 @@ Fetch active financing deals. Unlike other data endpoints, this returns an HTML
## Endpoint ## Endpoint
``` ```
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1 GET https://faisamobilex-wv-v2.mib.com.mv/financing?dashurl=1
``` ```
--- ---
+84 -59
View File
@@ -1,92 +1,117 @@
# Personal Profile # 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. 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` |
``` ### Full name — `A41`
GET https://faisamobilex-wv.mib.com.mv/personalProfile
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 ### Alias Suggestions — `getAliasTypes`
Session cookies only — no additional AJAX headers required.
``` ```
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` ```
Referer: https://faisamobilex-wv-v2.mib.com.mv/Alias/Alias?dashurl=1
The page contains an `<h5>` with the user's full name and `<span>` elements with labelled fields.
### Parsing Strategy
**Full name** — extracted from:
```html
<h5 class="mb-1 text-dark fw-semibold">Mohamed Ali</h5>
``` ```
Regex: 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)).
```kotlin
Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""") #### 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: | `id` | `aliasTypeName` | Used as |
```html
<span ...><b ...>Username:</b ...>...<span ...>myusername</span>
```
Regex (used for each label):
```kotlin
Regex(
"""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE)
)
```
### Extracted Fields
| Label in HTML | Field | Description |
|---|---|---| |---|---|---|
| `Username:` | `username` | Login username | | `0` | Mobile Number | Mobile number (with `+960` prefix) |
| `Email:` | `email` | Registered email address | | `1` | E-Mail | Email |
| `Mobile no:` | `mobile` | Registered mobile number | | `3` | National ID | ID card number |
| `Enrolled:` | `enrolled` | Enrollment date or status |
Combined with the `fullName` from the `<h5>`: A type may be missing from `data` when the user has no value for it.
```kotlin ### Registered Aliases — `ajaxAlias/main`
data class MibPersonalProfile(
val fullName: String, ```
val username: String, POST https://faisamobilex-wv-v2.mib.com.mv/ajaxAlias/main
val email: String,
val mobile: String,
val enrolled: String
)
``` ```
### 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). Body: `page=1&search=&searchAccount=&searchAliasType=&sort=AlsId&sortDir=asc&start=1&end=20&includeCount=1`
- This endpoint does not have a JSON equivalent — scraping is the only method.
```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 ## 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 | | `routePath` | Operation | Source |
|---|---|---| |---|---|---|
@@ -101,10 +126,10 @@ The same conceptual value — "the hash that identifies a customer's avatar" —
| Source endpoint | Field name | | Source endpoint | Field name |
|---|---| |---|---|
| `A41` login init (`operatingProfiles[]`) | `customerImage` | | `A41` login init (`operatingProfiles[]`) | `customerImage` |
| `A41` login init (root) | `customerImgHash` |
| `C41` registration 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` ### Fetch — `routePath: P41`
+209 -149
View File
@@ -2,197 +2,244 @@
Two-step process: look up the recipient to validate the account and get the holder name, then execute the transfer. 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. 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)).
```
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
```
--- ---
## 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 `7`, exactly 13 digits | `7700000000000` | BML / local bank account |
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` | | Starts with `9`, exactly 17 digits | `90100000000000000` | MIB account |
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` | | Starts with `7` or `9`, exactly 7 digits | `9600001` | Favara mobile alias |
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` | | `A` followed by 6 digits | `A000001` | Favara National ID alias |
| Contains `@` (email) | `AjaxAlias/getAlias` | `aliasName` | | Contains `@` | `user@example.com` | Favara email alias |
--- **Request**:
### 1a. IPS Account — BML / local bank (13 digits, starts with `7`)
```
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount
```
Body: `benefAccount=7700000000000`
**Response**:
```json ```json
{ {
"success": true, "sfunc": "n",
"responseCode": "2", "xxid": "<session xxid>",
"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",
"data": { "data": {
"BfyNm": "Account Holder Name", "search": "7700000000000",
"CdtrAcct": { "nonce": "<computed nonce>",
"Acct": "90100000000000000", "appId": "<appId>",
"FinInstnId": "MADVMVMV" "sodium": "<random 20-bit int>",
} "routePath": "B40",
"xxid": "<session xxid>"
} }
} }
``` ```
- `BfyNm` — beneficiary name (trim whitespace) ### Input Rules
- `CdtrAcct.Acct` — resolved account number to use for the transfer
- `CdtrAcct.FinInstnId` — bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML)
Use `bankNo=2` (MIB) or `3` (BML/local) depending on `FinInstnId`, and the matching transfer endpoint. - **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.
--- ### Response
### 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
**BML account**:
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": [
{ {
"trxId": "TRX20260516001", "benefName": "ACCOUNT HOLDER NAME",
"date": "2026-05-16 15:10:25" "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 | | Field | Description |
|---|---| |---|---|
| `trxId` | Transaction ID | | `trxId` | Transaction ID |
| `date` | Completion timestamp | | `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 ### Response — Failure
Failures return HTTP `400` with an encrypted body:
```json ```json
{ {
"success": false, "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 ## Transfer Type Summary
| Recipient | Lookup endpoint | `bankNo` | Transfer endpoint | | Recipient | Lookup | `bankNo` | Transfer |
|---|---|---|---| |---|---|---|---|
| MIB (17-digit `9…`) | `getAccountName` | `2` | `transferInternal` | | MIB (17-digit `9…`) | `B40` | `2` | `T50` |
| BML (13-digit `7…`) | `getIPSAccount` | `3` | `transferLocal` | | BML (13-digit `7…`) | `B40` | `3` | `T51` |
| Favara alias → MIB | `getAlias` | `2` | `transferInternal` | | Favara alias → MIB | `B40` | `2` | `T50` |
| Favara alias → BML | `getAlias` | `3` | `transferLocal` | | 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) ## `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 | | Field | Description |
|---|---| |---|---|
| `accountName` | Account holder name (trimmed) | | `accountName` | Account holder name (trimmed) |
| `accountNumber` | Resolved account number | | `accountNumber` | Resolved account number |
| `bankId` | Bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML, etc.) | | `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 ## 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`. - **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**: HTTP `419` on either lookup or transfer means the session expired. See [README](README.md) for the unified expiry detection rules. - **Session expiry**: see [README](README.md) for the unified expiry detection rules.
--- ---
+187 -132
View File
@@ -1,62 +1,58 @@
# Contacts (Beneficiaries) # 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.
``` Contacts belong to the **currently selected** profile — call `P47` for the profile first (see [03-accounts.md](03-accounts.md)).
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
``` Every write returns a `beneficiaryVersion` counter that increments on each change.
--- ---
## List Categories ## List Contacts — `routePath: B41`
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories
```
Empty POST body.
### Response
**Request**:
```json ```json
{ {
"success": true, "sfunc": "n",
"data": [ "xxid": "<session xxid>",
{ "id": "100001", "categoryName": "Myself", "numBenef": "2" }, "data": {
{ "id": "100002", "categoryName": "Friends", "numBenef": "10" }, "start": 1,
{ "id": "100003", "categoryName": "Business", "numBenef": "8" }, "end": 10,
{ "id": "100004", "categoryName": "Family", "numBenef": "5" } "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 | | Field | Example | Description |
|---|---|---| |---|---|---|
| `page` | `1` | Page number (1-based) | | `start` | `1` | First row (`rnum`, 1-based, inclusive) |
| `search` | `` | Search query (empty = all) | | `end` | `10` | Last row (inclusive) |
| `searchCategoryId` | `0` | Category filter (`0` = all) |
| `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift | | `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
| `sortBenef` | `name` | Sort field | | `search` | `` | Search query (empty = all) |
| `sortDir` | `asc` | Sort direction |
| `start` | `1` | Start record index (1-based) |
| `end` | `100` | End record index |
| `includeCount` | `1` | Include `total_count` | | `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 ### Beneficiary Types
@@ -71,24 +67,42 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
```json ```json
{ {
"success": true, "success": true,
"total_count": "48", "responseCode": "1",
"reasonCode": "103",
"reasonText": "beneficiary retrieval success",
"total_count": "47",
"pos": "0",
"data": [ "data": [
{ {
"benefNo": "100001", "benefNo": "100001",
"benefName": "Person Name", "benefName": "Person Name",
"benefNickName": "Nickname",
"benefAccount": "7700000000001", "benefAccount": "7700000000001",
"benefType": "L", "benefType": "L",
"bankColor": "#AC0000", "bankColor": "#AC0000",
"benefBankName": "Bank of Maldives PLC", "benefBankName": "Bank of Maldives PLC",
"bankCode": "BML", "bankCode": "BML",
"benefSwiftCode": "MALBMVMV",
"benefStatus": "A", "benefStatus": "A",
"benefBankId": "3", "benefBankId": "3",
"benefNickName": "Nickname",
"benefSwiftCode": "MALBMVMV",
"transferCy": "462", "transferCy": "462",
"transferCyDesc": "MVR", "transferCyDesc": "MVR",
"customerImgHash": "abcd1234hash", "customerImgHash": "<image hash>",
"benefCategoryID": "100002" "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 | | 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 | | `benefName` | yes | Legal/full name; used as fallback when nickname is blank |
| `benefNickName` | yes | User-assigned nickname (prefer over `benefName` for display) | | `benefNickName` | yes | User-assigned nickname (prefer over `benefName` for display) |
| `benefAccount` | yes | Account number | | `benefAccount` | yes | Account number |
| `benefType` | yes | `L`, `I`, or `S` | | `benefType` | yes | `L`, `I`, or `S` |
| `bankColor` | yes | Hex color for placeholder avatar background | | `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"`) | | `bankCode` | yes | Short bank code (e.g. `"BML"`, `"MIB"`) |
| `benefStatus` | yes | Beneficiary status (`"A"` = active) | | `benefStatus` | yes | Beneficiary status (`"A"` = active) |
| `transferCyDesc` | yes | Currency (e.g. `"MVR"`, `"USD"`) | | `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 | | `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 | | `benefBankId` | server-only | Numeric bank ID — not consumed |
| `transferCy` | server-only | Currency numeric code (e.g. `"462"`) — only `transferCyDesc` is 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 ### Response
```json ```json
{ {
"success": true, "success": true,
"msg": "image found",
"message": "image found",
"profileImage": "<base64-encoded JPEG>" "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 | | Field | Description |
|---|---| |---|---|
| `benefType` | `"I"` = MIB internal, `"L"` = local/BML | | `benefName` | Full name (from account lookup) |
| `benefAccount` | Beneficiary account number | | `benefAccount` | Beneficiary account number |
| `benefName` | Full name | | `bankNo` | `"2"` = MIB, `"3"` = BML |
| `nickName` | Display nickname | | `nickName` | Display nickname — **max 20 characters** |
| `bankNo` | `2` = MIB, `3` = BML/local | | `transferCy` | Currency numeric code (`"462"` = MVR, `"840"` = USD) |
| `transferCy` | Currency numeric code (`"462"` = MVR) |
| `categoryId` | Category ID (`"0"` = uncategorized) | | `categoryId` | Category ID (`"0"` = uncategorized) |
| `imageSet` | `"1"` if image provided, `"0"` otherwise | | `image` | Base64 JPEG photo. Omit the field when there is no photo. |
| `image` | Base64-encoded image (empty string if none) | | `bankName` | Bank name from the login bank list (`A41` `bankData`) |
| `benefIban` | Leave blank | | `bankSwift` | Bank BIC from the same list |
| `benefAddress` | Leave blank |
| `benefCity` | Leave blank |
| `benefCountry` | `"4"` |
| `benefBankSwift` | Leave blank |
| `benefBankName` | Leave blank |
| `benefBankBranch` | Leave blank |
| `benefBankAddress` | Leave blank |
| `benefBankCity` | Leave blank |
| `benefBankCountry` | `"4"` |
| `intBankSwift` | Leave blank |
| `intBankName` | Leave blank |
| `intBankAddress` | Leave blank |
| `intBankBranch` | Leave blank |
| `intBankCity` | Leave blank |
| `intBankCountry` | `"4"` |
| `transferCySwift` | `"840"` (USD numeric — static) |
| `email` | Leave blank |
| `contactNumber` | Leave blank |
| `website` | Leave blank |
### Response | `bankNo` | `bankName` | `bankSwift` |
|---|---|---|
```json | `2` | Maldives Islamic Bank | `MADVMVMV` |
{ "success": true } | `3` | Bank of Maldives PLC | `MALBMVMV` |
```
`success: true` confirms the contact was saved.
---
## Delete Contact
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/deleteBeneficiary
```
Body: `benefNo=<benefNo>`
### Response
```json
{ "success": true }
```
---
## Contact Stats
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats
```
Empty POST body.
### Response ### Response
```json ```json
{ {
"success": true, "success": true,
"data": [ "responseCode": "1",
{ "type": "L", "count": "30" }, "reasonCode": "102",
{ "type": "I", "count": "10" }, "reasonText": "beneficiary creation success",
{ "type": "S", "count": "2" } "data": [ { "benefNo": "1336023", "benefNickName": "Nickname", "customerImgHash": "<image hash>", ... } ],
] "beneficiaryVersion": 8
} }
``` ```
Gives counts per beneficiary type. Useful for showing tab badges. ### Errors
A nickname over 20 characters is rejected with HTTP `400`:
```json
{ "success": false, "responseCode": "2", "reasonCode": "201", "reasonText": "invalid nick name", "errorKey": "nickName" }
```
---
## Update Contact — `routePath: B52`
Same fields as `B43`, plus:
| Field | Description |
|---|---|
| `benefNo` | Beneficiary to update |
| `imageChanged` | `"1"` when a new `image` is sent, `"0"` to keep the current photo |
**Request** — route fields:
```json
{
"benefName": "RECIPIENT NAME",
"benefAccount": "7700000000001",
"bankNo": "3",
"nickName": "New nickname",
"transferCy": "462",
"categoryId": "100002",
"benefNo": "240301",
"imageChanged": "0",
"bankName": "Bank of Maldives PLC",
"bankSwift": "MALBMVMV",
"routePath": "B52"
}
```
### Response
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "107",
"reasonText": "Beneficiary Updated Successfully",
"data": [ { "benefNo": "240301", "benefNickName": "New nickname", "customerImgHash": "<image hash>", ... } ],
"beneficiaryVersion": 6
}
```
Reload the list with `B41` afterwards to pick up the change.
---
## Delete Contact — `routePath: B48`
**Request** — route fields:
```json
{ "benefNo": "240301", "routePath": "B48" }
```
### Response
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "108",
"reasonText": "Beneficiary deleted Successfully",
"beneficiaryVersion": 7
}
```
--- ---
+2 -2
View File
@@ -9,7 +9,7 @@ Source: `MibActivityHistoryClient.kt`.
## Endpoint ## 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. 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 X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Linux; Android <ver>; wv) AppleWebKit/537.36 ... User-Agent: Mozilla/5.0 (Linux; Android <ver>; wv) AppleWebKit/537.36 ...
``` ```
+36 -10
View File
@@ -12,8 +12,8 @@ MIB uses **two completely separate backends**:
| Backend | Base URL | Auth | Used for | | Backend | Base URL | Auth | Used for |
|---|---|---|---| |---|---|---|---|
| Encrypted API | `https://faisanet.mib.com.mv/faisamobilex_smvc/` | Blowfish + DH session key | Login, key exchange | | 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.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing | | 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: 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: Two keys are used:
@@ -40,17 +54,17 @@ See [01-encryption.md](01-encryption.md) for full details.
## WebView Session Auth ## 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. These values come from the login flow — `xxid` and `nonceGenerator` from the DH key exchange response.
**Cookie notes:** **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. - `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 ### WebView AJAX Headers
@@ -60,7 +74,7 @@ All AJAX `POST` calls also require:
``` ```
X-Requested-With: XMLHttpRequest X-Requested-With: XMLHttpRequest
Accept: */* 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 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`. 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 ### 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 | | 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 | | 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 | | 4 | [04-history.md](04-history.md) | Transaction history |
| 5 | [05-cards.md](05-cards.md) | Debit card list | | 5 | [05-cards.md](05-cards.md) | Debit card list |
| 6 | [06-financing.md](06-financing.md) | Financing deals | | 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 | | 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer |
| 9 | [09-contacts.md](09-contacts.md) | Beneficiary management | | 9 | [09-contacts.md](09-contacts.md) | Beneficiary management |
| 10 | [10-activity-history.md](10-activity-history.md) | Activity / audit log | | 10 | [10-activity-history.md](10-activity-history.md) | Activity / audit log |
+1 -1
View File
@@ -105,7 +105,7 @@ Each Fahipay favourites list is one payout service (`FahipayService.contactCateg
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay | | `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay | | `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked: So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyServiceContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`) - the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
- the "To" field's search-as-you-type dropdown - the "To" field's search-as-you-type dropdown
@@ -0,0 +1,11 @@
- updated dhivehi transaltions (thank you @quillfires)
- improved fahipay support
- new UI to select transfer type (Favara, Reload, Raastas, Billpay)
- Ooredoo Raastas via Fahipay
- Ooredo Billpay via Fahipay
- Dhiraagu reload via Fahipay
- Dhiraagu billpay via Fahipay
- Ooredoo Raastas via BML verified cards
- Ooredoo Billpay via BML verified cards
- Dhiraagu Reload via BML verifed cards
- Dhiraagu billpay via BML verified cards
@@ -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