MIB v2 history, cards, contacts, transfers, contact edit

This commit is contained in:
2026-10-23 05:15:43 +05:00
parent 870d3eb0f6
commit 46edefe4d7
25 changed files with 508 additions and 540 deletions
@@ -11,7 +11,7 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private const val MIB_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
class MibActivityHistoryClient {
@@ -35,7 +35,7 @@ class MibActivityHistoryClient {
start: Int,
end: Int
): FetchResult {
val cookieHeader = "mbmodel=IOS-1.0; " +
val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
@@ -15,7 +15,7 @@ data class MibCardActionResult(
class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
@@ -25,7 +25,7 @@ class MibCardsClient {
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
@@ -1,135 +1,83 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibContactsClient {
class MibContactsClient(private val flow: MibLoginFlow) {
private val TAG = "MibContactsClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> =
fetchContactsAndCategories(session).second
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchContacts(session: MibSession): List<MibBeneficiary> =
fetchContactsAndCategories(session).first
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withSessionHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/beneficiary?dashurl=1")
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> {
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getCategories")
.post(FormBody.Builder().build())
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return emptyList()
parseCategories(response.body?.string() ?: return emptyList())
}
}
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> {
/**
* Pages through B41. Every page also carries the full category list.
* start/end select rows by their 1-based `rnum`, both ends inclusive.
*/
fun fetchContactsAndCategories(session: MibSession): Pair<List<MibBeneficiary>, List<MibBeneficiaryCategory>> {
val all = mutableListOf<MibBeneficiary>()
var page = 1
val pageSize = 100
val seen = mutableSetOf<String>()
var categories = emptyList<MibBeneficiaryCategory>()
val pageSize = 10 // the official app always requests 10-row windows
var start = 1
while (true) {
val start = (page - 1) * pageSize + 1
val end = page * pageSize
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
val (contacts, totalCount) = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return all
parseContacts(response.body?.string() ?: return all)
val resp = flow.request(session, "B41") {
put("start", start)
put("end", start + pageSize - 1)
put("benefType", "A")
put("search", "")
put("includeCount", 1)
put("searchCategoryId", 0)
put("sortBenef", "")
}
all.addAll(contacts)
if (all.size >= totalCount || contacts.isEmpty()) break
page++
if (!resp.optBoolean("success")) break
if (start == 1) categories = parseCategories(resp)
val totalCount = resp.optString("total_count", "0").toIntOrNull() ?: 0
val arr = resp.optJSONArray("data") ?: break
val page = (0 until arr.length()).map { parseContact(arr.getJSONObject(it)) }
for (c in page) if (seen.add(c.benefNo)) all.add(c)
val isLast = arr.length() == 0 ||
arr.getJSONObject(arr.length() - 1).optString("last") == "1"
if (isLast || all.size >= totalCount) break
start += pageSize
}
return all
return Pair(all, categories)
}
private fun parseContacts(json: String): Pair<List<MibBeneficiary>, Int> {
return try {
val obj = JSONObject(json)
val totalCount = obj.optString("total_count", "0").toIntOrNull() ?: 0
val arr = obj.getJSONArray("data")
val contacts = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
val hash = o.optString("customerImgHash")
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 parseCategories(resp: JSONObject): List<MibBeneficiaryCategory> {
val arr = resp.optJSONArray("categories") ?: return emptyList()
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
id = o.getString("id"),
categoryName = o.getString("categoryName"),
numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 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(
session: MibSession,
benefType: String, // "I" = MIB internal, "L" = local/BML
bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String,
benefName: String,
@@ -138,82 +86,62 @@ class MibContactsClient {
categoryId: String = "0",
imageBase64: String = ""
): Boolean {
val body = FormBody.Builder()
.add("benefType", benefType)
.add("imageSet", if (imageBase64.isNotBlank()) "1" else "0")
.add("benefAccount", benefAccount)
.add("benefIban", "")
.add("benefName", benefName)
.add("nickName", nickName)
.add("transferCy", transferCy)
.add("transferCySwift", "840")
.add("benefAddress", "")
.add("benefCity", "")
.add("benefCountry", "4")
.add("benefBankSwift", "")
.add("bankNo", bankNo.toString())
.add("benefBankName", "")
.add("benefBankBranch", "")
.add("benefBankAddress", "")
.add("benefBankCity", "")
.add("benefBankCountry", "4")
.add("intBankSwift", "")
.add("intBankName", "")
.add("intBankAddress", "")
.add("intBankBranch", "")
.add("intBankCity", "")
.add("intBankCountry", "4")
.add("categoryId", categoryId)
.add("email", "")
.add("contactNumber", "")
.add("website", "")
.add("image", imageBase64)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/createLocalBeneficiary")
.post(body)
.withSessionHeaders(session)
.header("Referer", "$BASE_WV_URL/beneficiary/createNew")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
val (bankName, bankSwift) = bankInfo(bankNo)
val resp = flow.request(session, "B43") {
put("benefName", benefName)
put("benefAccount", benefAccount)
put("bankNo", bankNo.toString())
put("nickName", nickName)
put("transferCy", transferCy)
put("categoryId", categoryId)
if (imageBase64.isNotBlank()) put("image", imageBase64)
put("bankName", bankName)
put("bankSwift", bankSwift)
}
return resp.optBoolean("success")
}
fun deleteContact(session: MibSession, benefNo: String): Boolean {
val body = FormBody.Builder().add("benefNo", benefNo).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
/** Updates a beneficiary via B52. A blank [imageBase64] keeps the current photo. */
fun updateContact(
session: MibSession,
benefNo: String,
bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String,
benefName: String,
nickName: String,
transferCy: String = "462",
categoryId: String = "0",
imageBase64: String = ""
): Boolean {
val (bankName, bankSwift) = bankInfo(bankNo)
val resp = flow.request(session, "B52") {
put("benefName", benefName)
put("benefAccount", benefAccount)
put("bankNo", bankNo.toString())
put("nickName", nickName)
put("transferCy", transferCy)
put("categoryId", categoryId)
put("benefNo", benefNo)
put("imageChanged", if (imageBase64.isNotBlank()) "1" else "0")
if (imageBase64.isNotBlank()) put("image", imageBase64)
put("bankName", bankName)
put("bankSwift", bankSwift)
}
return resp.optBoolean("success")
}
/** Bank name/SWIFT as listed by B49 for the bank IDs we support. */
private fun bankInfo(bankNo: Int): Pair<String, String> = when (bankNo) {
2 -> "Maldives Islamic Bank" to "MADVMVMV"
else -> "Bank of Maldives PLC" to "MALBMVMV"
}
fun deleteContact(session: MibSession, benefNo: String): Boolean =
flow.request(session, "B48") { put("benefNo", benefNo) }.optBoolean("success")
fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
val body = FormBody.Builder()
.add("imageHash", imageHash)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getProfileImage")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
try {
val json = response.body?.string() ?: return null
JSONObject(json).optString("profileImage").takeIf { it.isNotBlank() }
} catch (e: Exception) {
null
}
}
val resp = flow.request(session, "B46") { put("imageHash", imageHash) }
if (!resp.optBoolean("success")) return null
return resp.optString("profileImage").takeIf { it.isNotBlank() }
}
}
@@ -9,7 +9,7 @@ import kotlin.math.ceil
class MibFinancingClient {
private val TAG = "MibFinancingClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@@ -17,7 +17,7 @@ class MibFinancingClient {
.build()
fun fetchFinancing(session: MibSession): List<MibFinanceDeal> {
val cookieHeader = "mbmodel=IOS-1.0; " +
val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
@@ -1,28 +1,9 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
class MibHistoryClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
class MibHistoryClient(private val flow: MibLoginFlow) {
/**
* Fetches transaction history for a single MIB account.
* Fetches transaction history for a single MIB account via the encrypted `A84` route.
* @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
* @return Pair of (transactions, totalCount)
*/
@@ -33,58 +14,36 @@ class MibHistoryClient {
start: Int = 1,
pageSize: Int = 20
): Pair<List<Transaction>, Int> {
val body = FormBody.Builder()
.add("accountNo", accountNo)
.add("trxNo", "")
.add("trxType", "0")
.add("sortTrx", "date")
.add("sortDir", "desc")
.add("fromDate", "")
.add("toDate", "")
.add("start", start.toString())
.add("end", (start + pageSize - 1).toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxAccounts/trxHistory")
.post(body)
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//accountDetails?trxh=1&dashurl=1&accountNo=$accountNo")
.build()
return client.newCall(request).execute().use { response ->
if (response.code in 500..599) throw BankServerException("MIB")
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
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)
// A84 uses a 0-based inclusive range: first page is start=0, end=19
val json = flow.request(session, "A84") {
put("accountNo", accountNo)
put("start", start - 1)
put("end", start - 1 + pageSize - 1)
put("includeCount", true)
put("trxType", 0)
put("sortTrx", "date")
put("sortDir", "desc")
}
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
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)
}
}
@@ -305,6 +305,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
put("xxid", session.xxid)
}
/**
* Sends an encrypted `sfunc=n` request for [routePath] with the base fields plus [fields].
* Expired sessions are recovered the same way as login requests.
*/
fun request(session: MibSession, routePath: String, fields: JSONObject.() -> Unit = {}): JSONObject =
doRequest(session, baseData(session, routePath).apply(fields), "n")
/**
* Activates [profile] server-side via P47, setting the session role context so that
* subsequent WebView requests run under that profile.
@@ -35,7 +35,8 @@ data class MibTransferResult(
val success: Boolean,
val trxId: String = "",
val date: String = "",
val errorMessage: String = ""
val errorMessage: String = "",
val reasonCode: String = ""
)
@@ -20,10 +20,10 @@ class MibProfileClient {
.build()
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
val cookieHeader = "mbmodel=AND-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.url("https://faisamobilex-wv-v2.mib.com.mv/personalProfile")
.get()
.header("Cookie", cookieHeader)
.build()
@@ -1,42 +1,12 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibLookupException(message: String) : Exception(message)
class MibTransferClient {
private val TAG = "MibTransferClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/transfer/quick")
class MibTransferClient(private val flow: MibLoginFlow) {
/**
* Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal).
* currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank.
* Execute a transfer. bankNo=2 → MIB internal (T50), bankNo=3 → local/BML via Favara (T51).
* currencyCode: "462"=MVR, "840"=USD.
*/
fun transfer(
session: MibSession,
@@ -49,157 +19,79 @@ class MibTransferClient {
purpose: String,
otp: String
): MibTransferResult {
val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal"
val body = FormBody.Builder()
.add("benefName", benefName)
.add("benefNo", "0")
.add("fromAccountNo", fromAccount)
.add("benefAccountNo", toAccount)
.add("transferCy", currencyCode)
.add("benefCurrencyCode", currencyCode)
.add("amount", amount)
.add("bankNo", bankNo.toString())
.add("purpose", purpose.ifBlank { "-" })
.add("otp", otp)
.add("otpType", "3")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxTransfer/$endpoint")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) {
MibTransferResult(
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") ?: ""
)
}
val json = flow.request(session, if (bankNo == 2) "T50" else "T51") {
put("benefName", benefName)
put("benefNo", "0")
put("fromAccountNo", fromAccount)
put("benefAccountNo", toAccount)
put("transferCy", currencyCode)
put("benefCurrencyCode", currencyCode)
put("amount", amount)
put("bankNo", bankNo.toString())
put("purpose", purpose)
put("otp", otp)
put("otpType", "3")
}
if (!json.optBoolean("success")) {
return MibTransferResult(
success = false,
errorMessage = json.optString("reasonText").ifBlank { "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)
* - 17 digits starting with 9 → MIB internal account name lookup
* - 13 digits starting with 7 → BML / local bank account
* - 17 digits starting with 9 → MIB account
* - 7 digits starting with 7 or 9,
* A + 6 digits,
* or email address → Favara alias lookup
* or email address → Favara alias
*
* The format check only avoids sending inputs B40 can't resolve.
*/
fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
val trimmed = input.trim()
return when {
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed)
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed)
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.contains("@") -> lookupAlias(session, trimmed)
else -> throw MibLookupException("Unrecognized account number format")
}
}
// B40 only resolves Favara IDs with an uppercase "A" ("a239225" fails, "A239225" works)
val trimmed = input.trim().let { if (it.matches(Regex("^a\\d{6}$"))) it.uppercase() else it }
val supported = trimmed.matches(Regex("^7\\d{12}$")) ||
trimmed.matches(Regex("^9\\d{16}$")) ||
trimmed.matches(Regex("^[79]\\d{6}$")) ||
trimmed.matches(Regex("^[Aa]\\d{6}$")) ||
trimmed.contains("@")
if (!supported) throw MibLookupException("Unrecognized account number format")
/** BML / local bank: 15-digit account starting with 7. */
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("benefAccount", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getIPSAccount")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
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 json = flow.request(session, "B40") { put("search", trimmed) }
val match = json.optJSONArray("data")?.optJSONObject(0)
if (!json.optBoolean("success") || match == null) {
throw MibLookupException(
json.optString("reasonText").trim().ifBlank { "Account not found" }
)
}
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. */
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("accountNo", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getAccountName")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
// accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: ""
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
val currency = when (currencyCode) {
"840" -> "USD"
"462" -> "MVR"
else -> ""
}
MibIpsAccountInfo(
accountName = name.trim(),
accountNumber = accountNumber,
bankId = "MADVMVMV", // MIB
currency = currency
)
}
}
/** Favara alias: 7-digit shortcode (7/9 prefix), A-format ID, or email. */
private fun lookupAlias(session: MibSession, aliasName: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("aliasName", aliasName).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getAlias")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
val data = json.getJSONObject("data")
val cdtrAcct = data.getJSONObject("CdtrAcct")
MibIpsAccountInfo(
accountName = data.optString("BfyNm").trim(),
accountNumber = cdtrAcct.optString("Acct"),
bankId = cdtrAcct.optString("FinInstnId")
)
}
companion object {
/** reasonCode for a wrong OTP, e.g. one from the previous window. */
const val REASON_INVALID_OTP = "213"
/** reasonCode for an OTP the server has already accepted once. */
const val REASON_OTP_ALREADY_USED = "707"
}
}
@@ -294,7 +294,7 @@ class AccountHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
@@ -18,11 +18,13 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -32,6 +34,7 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile
@@ -67,6 +70,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedCategoryId: String = "0"
private var categories: List<BankContactCategory> = emptyList()
// Set when editing an existing MIB contact instead of adding a new one
private var editContact: BankContact? = null
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult
encodeImage(uri)
@@ -90,7 +96,49 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (selectedDest?.isBml == false) setupCategoryDropdown()
}
applyPrefillArgs()
if (!applyEditArgs()) applyPrefillArgs()
}
/** Switches the sheet into edit mode when opened via [forEdit]. Returns true if it did. */
private fun applyEditArgs(): Boolean {
val args = arguments ?: return false
val benefNo = args.getString(ARG_EDIT_BENEF_NO) ?: return false
val profileId = args.getString(ARG_EDIT_PROFILE_ID) ?: return false
val contact = viewModel.contacts.value?.firstOrNull { it.benefNo == benefNo && it.profileId == profileId }
val dest = destinations.firstOrNull { !it.isBml && it.mibProfile?.profileId == profileId }
if (contact == null || dest == null) {
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
dismiss()
return true
}
editContact = contact
selectedCategoryId = contact.benefCategoryId
categories = viewModel.contactCategories.value?.filter { it.id != "BML" } ?: emptyList()
binding.tvTitle.setText(R.string.contact_edit_title)
selectedDest = dest
binding.actvDestination.setText(dest.label, false)
binding.tilDestination.isEnabled = false
binding.actvGroup.setText(
categories.firstOrNull { it.id == contact.benefCategoryId }?.categoryName
?: getString(R.string.contact_no_group), false
)
updateMibOnlyVisibility()
mibLookupAccount = contact.benefAccount
binding.tvResultName.text = contact.benefName
binding.tvResultBank.text = "${contact.benefBankName} · ${contact.benefAccount}"
val photo = contact.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
binding.ivResultAvatar.setImageBitmap(photo ?: makeInitialsBitmap(contact.benefName, contact.bankColor))
if (photo != null) binding.ivContactImage.setImageBitmap(photo)
binding.cardResult.visibility = View.VISIBLE
binding.tilAccount.visibility = View.GONE
binding.btnClearResult.visibility = View.GONE
binding.etAlias.setText(contact.benefNickName)
binding.etCurrency.setText(contact.transferCyDesc)
updateSaveState()
return true
}
private fun applyPrefillArgs() {
@@ -201,6 +249,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE
binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE
if (isMib) setupCategoryDropdown()
updateSaveState()
}
/**
* Save is enabled once an account is resolved. MIB rejects nicknames longer than
* [MIB_MAX_ALIAS] characters, so show that under the alias field and keep Save disabled.
*/
private fun updateSaveState() {
val alias = binding.etAlias.text?.toString()?.trim() ?: ""
val tooLong = selectedDest?.isBml == false && alias.length > MIB_MAX_ALIAS
binding.tilAlias.error = if (tooLong) getString(R.string.contact_alias_mib_max, MIB_MAX_ALIAS) else null
binding.btnSave.isEnabled = !tooLong && mibLookupAccount != null
}
private fun setupCategoryDropdown() {
@@ -307,8 +367,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.anyMibSession() ?: return null
val mibFlow = app.anyMibFlow() ?: return null
return try {
val info = MibTransferClient().lookup(mibSess, input)
val info = MibTransferClient(mibFlow).lookup(mibSess, input)
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
@@ -328,8 +389,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val profile = dest.mibProfile ?: return null
val mibResult = try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input)
val flow = app.mibFlowFor(loginId)
flow.switchProfile(mibSess, profile)
val info = MibTransferClient(flow).lookup(mibSess, input)
BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
validationType = "MIB",
@@ -398,7 +460,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.etCurrency.setText(validation.currency)
binding.btnClearResult.setOnClickListener { clearLookupResult() }
binding.btnSave.isEnabled = true
updateSaveState()
}
private fun clearLookupResult() {
@@ -438,6 +500,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun setupSaveButton() {
binding.btnSave.setOnClickListener { performSave() }
binding.etAlias.addTextChangedListener(afterTextChanged = { updateSaveState() })
}
private fun performSave() {
@@ -447,6 +510,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = "Alias is required"
return
}
if (!dest.isBml && alias.length > MIB_MAX_ALIAS) {
updateSaveState()
return
}
binding.tilAlias.error = null
binding.btnSave.isEnabled = false
@@ -454,11 +521,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
if (dest.isBml) saveToBml(alias) else saveToMib(alias)
when {
editContact != null -> updateMib(alias)
dest.isBml -> saveToBml(alias)
else -> saveToMib(alias)
}
}
if (success) {
Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show()
reloadSavedProfileContacts(dest)
// Same reload as pull-to-refresh on the contacts page
(activity as? HomeActivity)?.loadAllContacts(showIndicator = true)
dismiss()
} else {
binding.btnSave.isEnabled = true
@@ -495,15 +567,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val isMibInternal = account.matches(Regex("^9\\d{16}$"))
val benefType = if (isMibInternal) "I" else "L"
val bankNo = if (isMibInternal) 2 else 3
val name = bmlLookup?.name ?: ""
return try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact(
MibContactsClient(app.mibFlowFor(loginId)).createContact(
session = mibSess,
benefType = benefType,
bankNo = bankNo,
benefAccount = account,
benefName = name,
@@ -515,6 +585,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} catch (_: Exception) { false }
}
private fun updateMib(alias: String): Boolean {
val contact = editContact ?: return false
val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val bankNo = if (contact.benefAccount.matches(Regex("^9\\d{16}$"))) 2 else 3
return try {
val flow = app.mibFlowFor(loginId)
flow.switchProfile(mibSess, profile)
MibContactsClient(flow).updateContact(
session = mibSess,
benefNo = contact.benefNo,
bankNo = bankNo,
benefAccount = contact.benefAccount,
benefName = contact.benefName,
nickName = alias,
transferCy = transferCy,
categoryId = selectedCategoryId,
imageBase64 = selectedImageBase64
)
} catch (_: Exception) { false }
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
@@ -532,35 +629,6 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return bm
}
private fun reloadSavedProfileContacts(dest: DestinationOption) {
// Use activity scope so the reload continues after sheet dismisses
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try {
if (dest.isBml) {
val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged)
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else {
val profile = dest.mibProfile ?: return@launch
val mibLoginId = dest.mibLoginId ?: return@launch
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.profileId != profile.profileId } + fresh
viewModel.contacts.postValue(merged)
val allMib = merged.filter { it.benefCategoryId != "BML" }
ContactsCache.save(requireContext(), allMib, viewModel.contactCategories.value ?: emptyList())
}
} catch (_: Exception) { }
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
@@ -570,10 +638,23 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
// MIB rejects longer nicknames with "invalid nick name"
private const val MIB_MAX_ALIAS = 20
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_RECIPIENT_NAME = "recipient_name"
private const val ARG_CURRENCY = "currency"
private const val ARG_EDIT_BENEF_NO = "edit_benef_no"
private const val ARG_EDIT_PROFILE_ID = "edit_profile_id"
/** Opens the sheet to edit an existing MIB contact (alias, group, photo). */
fun forEdit(contact: ContactDisplay) = AddContactSheetFragment().apply {
arguments = Bundle().apply {
putString(ARG_EDIT_BENEF_NO, contact.id)
putString(ARG_EDIT_PROFILE_ID, contact.profileId)
}
}
fun newInstance(
bmlProfileId: String? = null,
@@ -77,7 +77,15 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
}
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 {
val parent = contactsFragment
@@ -356,7 +356,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val base64 = if (hash in profileImageHashes) {
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -143,6 +143,8 @@ class ContactsFragment : Fragment() {
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
// triggerRefresh() only covers accounts; reload contacts from the banks too
(activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.isRefreshing = false
}
@@ -257,7 +259,7 @@ class ContactsFragment : Fragment() {
return
}
val sess = session ?: return
val client = MibContactsClient()
val client = MibContactsClient(app.anyMibFlow() ?: return)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -30,8 +30,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -428,10 +430,10 @@ class HomeActivity : AppCompatActivity() {
for (session in sessions) {
withContext(Dispatchers.IO) {
try {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
val cookieHeader = "mbmodel=AND-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
.url("https://faisamobilex-wv-v2.mib.com.mv/aProfile/keepAlive")
.post(ByteArray(0).toRequestBody())
.header("Cookie", cookieHeader)
.header("User-Agent", "okhttp/4.11.0")
@@ -1210,10 +1212,10 @@ fun applyNavLabelVisibility() {
}
}
private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return
private fun refreshBmlContacts(app: BasedBankApp): Job? {
if (app.bmlSessions.isEmpty()) return null
val store = CredentialStore(this)
lifecycleScope.launch {
return lifecycleScope.launch {
try {
val allBmlContacts = withContext(Dispatchers.IO) {
store.getBmlLoginIds().flatMap { loginId ->
@@ -1233,7 +1235,8 @@ fun applyNavLabelVisibility() {
}
}
fun loadAllContacts() {
/** Loads contacts from cache, then refreshes them from every bank. [showIndicator] shows the top refresh bar until done. */
fun loadAllContacts(showIndicator: Boolean = false) {
val app = application as BasedBankApp
val store = sh.sar.basedbank.util.CredentialStore(this)
// Populate ViewModel from cache immediately if empty
@@ -1249,16 +1252,24 @@ fun applyNavLabelVisibility() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
}
// Refresh all banks in background
val jobs = mutableListOf<Job>()
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))?.let { jobs.add(it) }
}
refreshBmlContacts(app)?.let { jobs.add(it) }
for ((_, session) in app.fahipaySessions) jobs.add(refreshFahipayContacts(session))
if (showIndicator && jobs.isNotEmpty()) {
setRefreshing(true)
lifecycleScope.launch {
jobs.joinAll()
setRefreshing(false)
}
}
refreshBmlContacts(app)
for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
}
private fun refreshFahipayContacts(session: FahipaySession) {
lifecycleScope.launch {
private fun refreshFahipayContacts(session: FahipaySession): Job {
return lifecycleScope.launch {
try {
val groups = withContext(Dispatchers.IO) {
FahipayContactsClient().fetchContacts(session)
@@ -1288,11 +1299,11 @@ fun applyNavLabelVisibility() {
return result
}
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>): Job? {
if (profiles.isEmpty()) return null
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient()
lifecycleScope.launch {
val contactsClient = MibContactsClient(flow)
return lifecycleScope.launch {
try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
@@ -1302,10 +1313,12 @@ fun applyNavLabelVisibility() {
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) {
val (profileContacts, profileCategories) =
contactsClient.fetchContactsAndCategories(session)
for (cat in profileCategories) {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
for (contact in profileContacts) {
if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId))
}
@@ -302,7 +302,7 @@ class TransferHistoryFragment : Fragment() {
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
@@ -382,7 +382,7 @@ class TransferHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
@@ -250,7 +250,7 @@ class TransferReceiptFragment : Fragment() {
val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -8,6 +8,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibTransferClient
@@ -40,6 +41,10 @@ class MibTransferHandler(
val session: MibSession?
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
/** The login flow matching [session], used for encrypted API calls. */
private val flow: MibLoginFlow?
get() = currentSource()?.loginTag?.removePrefix("mib_")?.let { app.mibFlowFor(it) } ?: app.anyMibFlow()
/** Any logged-in MIB session, ignoring which source is selected. */
val anySession: MibSession? get() = app.anyMibSession()
@@ -54,8 +59,9 @@ class MibTransferHandler(
*/
fun lookupDestination(accountNumber: String): LookupOutcome {
val sess = session ?: return LookupOutcome(null, notFound())
val flow = flow ?: return LookupOutcome(null, notFound())
return try {
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
LookupOutcome(MibTransferClient(flow).lookup(sess, accountNumber), null)
} catch (e: MibLookupException) {
LookupOutcome(null, e.message)
} catch (_: Exception) {
@@ -69,7 +75,8 @@ class MibTransferHandler(
*/
fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return ""
return try { MibTransferClient().lookup(sess, accountNumber).currency }
val flow = flow ?: return ""
return try { MibTransferClient(flow).lookup(sess, accountNumber).currency }
catch (_: Exception) { "" }
}
@@ -88,7 +95,7 @@ class MibTransferHandler(
val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash)
} else {
MibContactsClient().fetchProfileImageBase64(session, hash)
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(session, hash)
}
if (base64 == null) null else {
val bytes = Base64.decode(base64, Base64.DEFAULT)
@@ -100,8 +107,9 @@ class MibTransferHandler(
/**
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
* "CONNECTIVITY" when the network was unreachable.
* TOTP. If MIB rejects the OTP as invalid or already used, waits for the next OTP window and
* retries once; a second rejection is returned as a failure. Blocking — call from IO.
* Returns `(ok, message, receipt)`; the message is "CONNECTIVITY" when the network was unreachable.
*/
fun doTransfer(
src: BankAccount,
@@ -114,8 +122,7 @@ class MibTransferHandler(
): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val loginId = src.loginTag.removePrefix("mib_")
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
val otpSeed = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR"
@@ -145,17 +152,26 @@ class MibTransferHandler(
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val result = MibTransferClient().transfer(
session = sess,
fromAccount = src.accountNumber,
toAccount = destAccount,
amount = amount,
currencyCode = currencyCode,
benefName = destName.ifBlank { "Recipient" },
bankNo = bankNo,
purpose = remarks,
otp = otp
)
val client = MibTransferClient(app.mibFlowFor(loginId))
val send = { otp: String ->
client.transfer(
session = sess,
fromAccount = src.accountNumber,
toAccount = destAccount,
amount = amount,
currencyCode = currencyCode,
benefName = destName.ifBlank { "Recipient" },
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) {
val receipt = TransferReceiptData(
bank = "MIB",
@@ -181,4 +197,13 @@ class MibTransferHandler(
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
}
}
private companion object {
val OTP_REJECTED_CODES = setOf(
MibTransferClient.REASON_INVALID_OTP,
MibTransferClient.REASON_OTP_ALREADY_USED
)
// Small delay past the window boundary so device and server clocks agree on the new code
const val OTP_WINDOW_MARGIN_MS = 1_000L
}
}
@@ -27,14 +27,18 @@ object ContactManager {
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false
return try {
var flow = app.anyMibFlow() ?: return false
if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null)
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
if (profile != null && loginId != null) {
flow = app.mibFlowFor(loginId)
flow.switchProfile(sess, profile)
}
}
MibContactsClient().deleteContact(sess, contact.id)
MibContactsClient(flow).deleteContact(sess, contact.id)
} catch (_: Exception) { false }
}
}
@@ -127,7 +127,7 @@ class HistoryFetcher(private val account: BankAccount) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
@@ -18,6 +18,12 @@ object Totp {
return otp.toString().padStart(digits, '0')
}
/** Milliseconds until the next code becomes current. */
fun millisUntilNextPeriod(periodSeconds: Long = 30): Long {
val periodMillis = periodSeconds * 1000L
return periodMillis - System.currentTimeMillis() % periodMillis
}
private fun hotp(key: ByteArray, counter: Long, digits: Int): Int {
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
val mac = Mac.getInstance("HmacSHA1")
@@ -12,6 +12,7 @@
android:padding="16dp">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
+2
View File
@@ -330,6 +330,8 @@
<!-- Contact expand/delete -->
<string name="contact_edit">އުނިއިތުރުގެނޭ</string>
<string name="contact_edit_title">ކޮންޓެކްޓް އުނިއިތުރުގެނޭ</string>
<string name="contact_alias_mib_max">MIB ގައި ލިޔެވޭނީ ގިނަވެގެން %1$d އަކުރު</string>
<string name="contact_delete">ފުހެލާ</string>
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
+2
View File
@@ -341,6 +341,8 @@
<!-- Contact expand/delete -->
<string name="contact_edit">Edit</string>
<string name="contact_edit_title">Edit Contact</string>
<string name="contact_alias_mib_max">MIB allows a maximum of %1$d characters</string>
<string name="contact_delete">Delete</string>
<string name="contact_delete_title">Delete Contact</string>
<string name="contact_delete_message">Remove %s from your contacts?</string>
+55 -18
View File
@@ -25,13 +25,31 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
| `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
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
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` | ☐ |
@@ -42,8 +60,22 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
### 3. Fund Transfers
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
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` | ☐ |
@@ -56,9 +88,9 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `/ajaxDebitCard/fetchCardInfos` | Retrieve list of debit cards | `MibCardsClient.kt`, `PayWithCardFragment.kt` | ☐ |
| `/ajaxDebitCard/freeze` | Freeze/block debit card | `MibCardsClient.kt` | ☐ |
| `/ajaxDebitCard/unfreeze` | Unfreeze/unblock debit card | `MibCardsClient.kt` | ☐ |
| `/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 |
---
@@ -66,7 +98,7 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `/ajaxAccounts/trxHistory` | Fetch account transaction history (paginated) | `MibHistoryClient.kt`, `AccountHistoryFragment.kt`, `TransferHistoryFragment.kt` | ☐ |
| ~~`/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 |
---
@@ -90,7 +122,7 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `GET /financing?dashurl=1` | Fetch financing/loan deals (HTML parsing) | `MibFinancingClient.kt` | ☐ |
| `GET /financing?dashurl=1` | Fetch financing/loan deals (HTML parsing) | `MibFinancingClient.kt` | ✅ works on `-wv-v2` |
---
@@ -98,7 +130,7 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `POST /aProfile/keepAlive` | Periodic session keep-alive (empty body) | `HomeActivity.kt` | ☐ |
| `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 |
---
@@ -133,7 +165,7 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
| Type | URL |
|------|-----|
| **Encrypted API** | `https://faisamobilex-smvc-v2.mib.com.mv/index/` |
| **WebView/Data** | `https://faisamobilex-wv.mib.com.mv` |
| **WebView/Data** | `https://faisamobilex-wv-v2.mib.com.mv` (v1: `faisamobilex-wv`) |
---
@@ -141,10 +173,10 @@ This document tracks all MIB API calls used by thijooree and their upgrade statu
### Session Headers
```
Cookie: mbmodel=IOS-1.0; xxid=<session_id>; IBSID=<session_id>; mbnonce=<nonce>; time-tracker=597
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.mib.com.mv
Origin: https://faisamobilex-wv-v2.mib.com.mv
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
```
@@ -173,13 +205,18 @@ pgf03 = SHA256(clientSalt + SHA256(SHA256(password) + userSalt))
- [x] Test profile switching
### WebView Endpoints (Pending)
- [ ] Test beneficiary management (`/ajaxBeneficiary/*`)
- [ ] Test transfer endpoints (`/ajaxTransfer/*`, `/AjaxAlias/*`)
- [ ] Test debit card endpoints (`/ajaxDebitCard/*`)
- [ ] Test transaction history (`/ajaxAccounts/trxHistory`)
- [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`)
- [ ] Test user profile scraping (`/personalProfile`)
- [ ] Test financing (`/financing?dashurl=1`)
- [x] Test financing (`/financing?dashurl=1`) — works on `-wv-v2`
- [ ] Test session keep-alive (`/aProfile/keepAlive`)
### Validation