Compare commits

..
6 Commits
Author SHA1 Message Date
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
34 changed files with 816 additions and 571 deletions
+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 = 35 versionCode = 36
versionName = "1.0.35" 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
@@ -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
View File
@@ -330,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>"
+1 -1
View File
@@ -12,7 +12,7 @@ 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, key exchange |
| WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing | | WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing |
--- ---
+237
View File
@@ -0,0 +1,237 @@
# MIB API v1 to v2 Upgrade Tracking
This document tracks all MIB API calls used by thijooree and their upgrade status from v1 to v2.
---
## Summary
**Total API Endpoints:** 28+
**Categories:** 8
**Last Updated:** 2026-10-22
---
## API Endpoints by Category
### 1. Authentication & Login (Encrypted API)
| API Code | Endpoint | Purpose | Used By | Status |
|----------|----------|---------|---------|--------|
| `sfunc=r` + `S40` | Key Exchange (Init) | First-time device registration | `MibLoginFlow.kt` | ✅ |
| `sfunc=i` + `S40` | Key Exchange (Login) | Regular login session setup | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `C41` | Registration Init | Initialize device registration flow | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `C42` | OTP Verification | Verify one-time password after registration | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `A41` | Main Login | Submit username/password credentials | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `A44` | Get User Salt | Retrieve user salt for password hashing | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `P47` | Switch Profile | Activate different user profile | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `A80` | Accounts | Refresh account list/balances for active profile (same shape as P47 `accountBalance`) | — (not used yet) | ℹ️ |
| `sfunc=n` + `A83` | GIA Accounts | `{start:1,end:30,statusType:0,includeCount:0}` | — (not used) | ℹ️ |
| `sfunc=n` + `A47` | Pending approvals | `{status:"P",start:0,end:30,includeCount:1}` (business profiles) | — (not used) | ℹ️ |
| `sfunc=n` + `P41` | Profile image | `{imageHash}` → base64 JPEG in `profileImage` | — (not used yet) | ℹ️ |
| `sfunc=n` + `B46` | Beneficiary image | `{imageHash}` → base64 JPEG in `profileImage` | — (not used yet) | ℹ️ |
| `sfunc=i` + `P80` | Promo banners | Encrypted with **key1**, not session key | — (not used) | ℹ️ |
---
### 2. Beneficiary & Contact Management
v2 moved all contact calls to the encrypted API (`sfunc=n`). Bank IDs/names/SWIFT come from `B49` (login).
| v1 endpoint | v2 route | Request | Used By | Status |
|-------------|----------|---------|---------|--------|
| `/ajaxBeneficiary/getCategories` | `B41` | Categories come back in every `B41` response as `categories[]` — no separate call | `MibContactsClient.kt`, `HomeActivity.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/main` | `B41` | `{start,end,benefType:"A",search:"",includeCount:1,searchCategoryId:0,sortBenef:""}` — selects 1-based `rnum` between start/end inclusive; last row has `last:"1"` | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/createLocalBeneficiary` | `B43` | `{benefName,benefAccount,bankNo,nickName,transferCy,categoryId,image?,bankName,bankSwift}` — nickname > ~20 chars → `invalid nick name` | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | 🔧 implemented (BML captured; MIB-internal bankName/swift from B49, not captured) |
| — (new) | `B52` | Update: B43 fields + `benefNo`, `imageChanged:"0"/"1"`, `image` when changed | `MibContactsClient.kt`, `AddContactSheetFragment.kt` (edit mode), `ContactDetailsSheetFragment.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/deleteBeneficiary` | `B48` | `{benefNo}` | `MibContactsClient.kt`, `ContactManager.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/getProfileImage` | `B46` | `{imageHash}` → `profileImage` base64 JPEG | `MibContactsClient.kt` + image loaders | 🔧 implemented — needs device test |
| — | `B40` | Account name lookup `{search}` → `[{benefName,benefAccount,bankCode,benefBankName}]` | — (check against transfer lookups) | ℹ️ |
| — | `T63` | Currencies + rates | — (not used) | ℹ️ |
----------|---------|---------|--------|
| `/ajaxBeneficiary/getCategories` | Fetch beneficiary category list | `MibContactsClient.kt` | ☐ |
| `/ajaxBeneficiary/main` | List all contacts (paginated) | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | ☐ |
| `/ajaxBeneficiary/createLocalBeneficiary` | Add new beneficiary | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | ☐ |
| `/ajaxBeneficiary/deleteBeneficiary` | Remove beneficiary | `MibContactsClient.kt`, `ContactsFragment.kt` | ☐ |
| `/ajaxBeneficiary/getProfileImage` | Get contact profile picture | `MibContactsClient.kt` | ☐ |
---
### 3. Fund Transfers
v2 moved transfers and all destination lookups to the encrypted API (`sfunc=n`). Failures come back as HTTP 400 with an encrypted body.
| v1 endpoint | v2 route | Request / notes | Used By | Status |
|-------------|----------|-----------------|---------|--------|
| `/ajaxTransfer/transferInternal` | `T50` | `{benefName,benefNo:"0",fromAccountNo,benefAccountNo,transferCy,benefCurrencyCode,amount,bankNo:"2",purpose,otp,otpType:"3"}` → `data[0].{date,trxId,availableBalance}` + `accountBalance[]` | `MibTransferClient.kt`, `MibTransferHandler.kt` | 🔧 implemented — needs device test |
| `/ajaxTransfer/transferLocal` | `T51` | Same as T50 with `bankNo:"3"` → `data[0].{date,trxId}` ("Local transfer completed successfully via Favara") | `MibTransferClient.kt`, `MibTransferHandler.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/getAccountName` | `B40` | `{search}` → `data[0].{benefName,benefAccount,bankCode,benefBankName,transferCy?,transferCyDesc?}`; `bankCode` is `"MIB"` or a BIC (`MADVMVMV`, `MALBMVMV`); empty `data` = not found | `MibTransferClient.kt` | 🔧 implemented — needs device test |
| `/AjaxAlias/getIPSAccount` | `B40` | as above | `MibTransferClient.kt` | 🔧 implemented — needs device test |
| `/AjaxAlias/getAlias` | `B40` | as above; phone must be bare 7 digits (`+960 …` returns empty); Favara ID needs uppercase `A` (`a239225` fails) | `MibTransferClient.kt` | 🔧 implemented — needs device test |
| — | `T71` / `T66` / `T63` / `T70` | AML limits / USD sell rate / currencies / upload settings | — (not used) | ℹ️ |
**Transfer errors seen:**
- `reasonCode 213` "Invalid OTP" (`data[0].otpId`) and `reasonCode 707` "That OTP is already used!" → thijooree waits for the next TOTP window and retries once, then shows the error.
- `reasonCode 201` "Transaction currency does not match From Account currency!"
----------|---------|---------|--------|
| `/ajaxTransfer/transferInternal` | MIB-to-MIB internal transfer (Bank code 2) | `MibTransferClient.kt`, `TransferFragment.kt` | ☐ |
| `/ajaxTransfer/transferLocal` | Transfer to BML account (Bank code 3) | `MibTransferClient.kt`, `TransferFragment.kt` | ☐ |
| `/ajaxBeneficiary/getAccountName` | Lookup MIB account (17-digit format) | `MibTransferClient.kt`, `MibTransferHandler.kt` | ☐ |
| `/AjaxAlias/getIPSAccount` | Lookup IPS/BML account (15-digit format) | `MibTransferClient.kt`, `MibTransferHandler.kt` | ☐ |
| `/AjaxAlias/getAlias` | Lookup Favara alias (shortcodes, IDs, email) | `MibTransferClient.kt`, `MibTransferHandler.kt` | ☐ |
---
### 4. Debit Card Operations
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `/ajaxDebitCard/fetchCardInfos` | Retrieve list of debit cards | `MibCardsClient.kt`, `PayWithCardFragment.kt` | ✅ host switched, tested |
| `/ajaxDebitCard/freeze` | Freeze/block debit card | `MibCardsClient.kt` | ✅ host switched, tested |
| `/ajaxDebitCard/unfreeze` | Unfreeze/unblock debit card | `MibCardsClient.kt` | ✅ host switched, tested |
---
### 5. Transaction History
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| ~~`/ajaxAccounts/trxHistory`~~ → `sfunc=n` + `A84` | Fetch account transaction history (paginated). v2 moved this to the encrypted API: `{accountNo,start:0,end:19,includeCount:true,trxType:0,sortTrx:"date",sortDir:"desc"}` (0-based inclusive), response fields unchanged, `total_count` present | `MibHistoryClient.kt`, `HistoryFetcher.kt`, `TransferHistoryFragment.kt` | ✅ implemented, tested incl. paging |
---
### 6. Activity & Audit Logs
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `/aProfile/getPagedActivityHistory` | Fetch activity alerts and audit logs (paginated) | `MibActivityHistoryClient.kt` | ☐ |
---
### 7. User Profile
| v1 endpoint | v2 source | Notes | Used By | Status |
|-------------|-----------|-------|---------|--------|
| `GET /personalProfile` (HTML scrape) | `A41` login response | `fullName`, `email` | `MibLoginFlow.kt` | 🔧 implemented — needs device test |
| — | `POST /ajaxAlias/getAliasTypes` (`-wv-v2`, empty body) | `data[].{id,aliasTypeName,suggestedAlias}`: `0` mobile, `1` email, `3` National ID. Tied to the active profile, so the personal profile (`profileType 0`) is selected first. Fetched at login and cached. | `MibProfileClient.kt`, `SettingsLoginsFragment.kt` | 🔧 implemented — needs device test |
| — | `POST /ajaxAlias/main` | Registered Favara aliases (`AlsBfy`, `CdtrAcct`, `AdrLine`, …) | — (not used) | ℹ️ |
----------|---------|---------|--------|
| `GET /personalProfile` | HTML scrape: fullName, username, email, mobile, enrolled date | `MibProfileClient.kt` | ☐ |
---
### 8. Financing & Loans
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `GET /financing?dashurl=1` | Fetch financing/loan deals (HTML parsing) | `MibFinancingClient.kt` | ✅ works on `-wv-v2` |
---
### 9. Session Management
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `POST /aProfile/keepAlive` | Periodic session keep-alive (empty body) | `HomeActivity.kt` | 🔧 host switched (seen on `-wv-v2` in HAR, 419 after expiry) — needs device test |
---
## Implementation Files Reference
### Core MIB API Clients
- `app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt` - Authentication
- `app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt` - Transfers & account lookup
- `app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt` - Contacts/beneficiaries
- `app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt` - Card operations
- `app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt` - Transaction history
- `app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt` - Activity logs
- `app/src/main/java/sh/sar/basedbank/api/mib/MibProfileClient.kt` - User profile
- `app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt` - Financing
### Security & Utilities
- `app/src/main/java/sh/sar/basedbank/api/mib/MibCrypto.kt` - AES encryption/decryption
- `app/src/main/java/sh/sar/basedbank/api/mib/MibNonce.kt` - Nonce generation (anti-replay)
- `app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt` - Data models
- `app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt` - Credential storage
### Parsers & Handlers
- `app/src/main/java/sh/sar/basedbank/util/mibapi/MibHistoryParser.kt`
- `app/src/main/java/sh/sar/basedbank/util/mibapi/MibAccountParser.kt`
- `app/src/main/java/sh/sar/basedbank/util/mibapi/MibContactParser.kt`
- `app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt`
---
## Base URLs
| Type | URL |
|------|-----|
| **Encrypted API** | `https://faisamobilex-smvc-v2.mib.com.mv/index/` |
| **WebView/Data** | `https://faisamobilex-wv-v2.mib.com.mv` (v1: `faisamobilex-wv`) |
---
## Authentication & Security
### Session Headers
```
Cookie: mbmodel=AND-1.0; xxid=<session_id>; IBSID=<session_id>; mbnonce=<nonce>; time-tracker=597
X-Requested-With: XMLHttpRequest
Accept: */*
Origin: https://faisamobilex-wv-v2.mib.com.mv
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
```
### Session Expiry Detection
- HTTP `419` status code
- JSON `reasonCode == "505"` in encrypted response
- Auto-recovery: re-login and retry (once)
### Password Hashing (PGF03)
```
pgf03 = SHA256(clientSalt + SHA256(SHA256(password) + userSalt))
```
---
## Upgrade Checklist
### Core Implementation
- [x] Update encrypted API endpoint URL
- [x] Update User-Agent header
- [x] Add X-Request-ID header
- [x] Verify Blowfish encryption compatibility
- [x] Test device registration flow
- [x] Test regular login flow
- [x] Test OTP verification
- [x] Test profile switching
### WebView Endpoints (Pending)
- [x] Switch all WebView clients to `faisamobilex-wv-v2` host and `mbmodel=AND-1.0` cookie
- [x] Capture beneficiary management — moved to encrypted `B4x`/`B52`, implemented
- [ ] Test contacts on device (list, add with/without photo, edit, delete, images)
- [x] Capture transfer endpoints — moved to encrypted `T50`/`T51`/`B40`, implemented with OTP retry
- [ ] Test transfers on device (MIB→MIB, MIB→BML, lookups incl. Favara alias)
- [x] Capture debit card endpoints (`/ajaxDebitCard/*`) — unchanged apart from host
- [x] Test debit card endpoints on device
- [x] Capture transaction history — moved to encrypted `A84`, implemented
- [x] Test transaction history on device (incl. page 2)
- [ ] Test activity history (`/aProfile/getPagedActivityHistory`)
- [x] Capture user profile — `/personalProfile` scrape replaced by A41 + `getAliasTypes`
- [ ] Test login details (name, email, mobile, ID card) on device
- [x] Test financing (`/financing?dashurl=1`) — works on `-wv-v2`
- [ ] Test session keep-alive (`/aProfile/keepAlive`)
### Validation
- [ ] Verify all account balances display correctly
- [ ] Verify error handling (419, 505) and auto-recovery
- [ ] Verify all data formats match expectations
- [ ] Full end-to-end user flow test
---
**Implementation:** 2026-10-22 ✅
**WebView Testing:** In progress
@@ -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