diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt index 5a0e76e..153976e 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt @@ -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}; " + diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt index b81788e..2cf02c9 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt @@ -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 { diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt index d685eb1..1c61734 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt @@ -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 = + fetchContactsAndCategories(session).second - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() + fun fetchContacts(session: MibSession): List = + 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 { - 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 { - 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 { + /** + * 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> { val all = mutableListOf() - var page = 1 - val pageSize = 100 + val seen = mutableSetOf() + var categories = emptyList() + 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, 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 { + 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 = 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() } } } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt index eb0e3a2..1546baf 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt @@ -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 { - val cookieHeader = "mbmodel=IOS-1.0; " + + val cookieHeader = "mbmodel=AND-1.0; " + "xxid=${session.xxid}; " + "IBSID=${session.xxid}; " + "mbnonce=${session.nonceGenerator}; " + diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt index 4f25d20..ba61a13 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt @@ -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, 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) } } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index e2d08e7..bc87bac 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -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. diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt index 19033ee..12b8378 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt @@ -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 = "" ) diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibProfileClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibProfileClient.kt index e368000..d3c2953 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibProfileClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibProfileClient.kt @@ -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() diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt index f69b11e..c4d5b4f 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt @@ -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" } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt index 4719190..bdbb307 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt index 09e7b8d..f586844 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt @@ -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 = 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, diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt index b0c756b..c0e2d9a 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index 80f9bda..15a7f78 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index 4dc9fa3..3327fa0 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index ea90535..f65963b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -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() 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) { - if (profiles.isEmpty()) return + private fun refreshContacts(loginId: String, session: MibSession, profiles: List): 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() @@ -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)) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt index 9067e67..f08b31d 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt index 13f0a48..147ca61 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt index 8c670aa..0302d14 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt @@ -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 { 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 + } } diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt b/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt index 5cc3e1d..78be509 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt @@ -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 } } } diff --git a/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt b/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt index 38f9d4b..80b211c 100644 --- a/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt +++ b/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt @@ -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, diff --git a/app/src/main/java/sh/sar/basedbank/util/Totp.kt b/app/src/main/java/sh/sar/basedbank/util/Totp.kt index 43d9735..a9a05b1 100644 --- a/app/src/main/java/sh/sar/basedbank/util/Totp.kt +++ b/app/src/main/java/sh/sar/basedbank/util/Totp.kt @@ -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") diff --git a/app/src/main/res/layout/sheet_add_contact.xml b/app/src/main/res/layout/sheet_add_contact.xml index 47364ce..f337bf1 100644 --- a/app/src/main/res/layout/sheet_add_contact.xml +++ b/app/src/main/res/layout/sheet_add_contact.xml @@ -12,6 +12,7 @@ android:padding="16dp"> އުނިއިތުރުގެނޭ + ކޮންޓެކްޓް އުނިއިތުރުގެނޭ + MIB ގައި ލިޔެވޭނީ ގިނަވެގެން %1$d އަކުރު ފުހެލާ ކޮންޓެކްޓް ފުހެލުން %s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3aaffaa..a17c718 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -341,6 +341,8 @@ Edit + Edit Contact + MIB allows a maximum of %1$d characters Delete Delete Contact Remove %s from your contacts? diff --git a/docs/mibapi/upgradev2.md b/docs/mibapi/upgradev2.md index 315295c..d3c5b46 100644 --- a/docs/mibapi/upgradev2.md +++ b/docs/mibapi/upgradev2.md @@ -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=; IBSID=; mbnonce=; time-tracker=597 +Cookie: mbmodel=AND-1.0; xxid=; IBSID=; mbnonce=; 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