add support for mib loans view

This commit is contained in:
2026-05-14 05:24:18 +05:00
parent d28687bc1c
commit 69b7862e65
15 changed files with 896 additions and 4 deletions
@@ -4,12 +4,16 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
class BasedBankApp : Application() { class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout // Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList() var accounts: List<MibAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -0,0 +1,82 @@
package sh.sar.basedbank.api.mib
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
import kotlin.math.ceil
class MibFinancingClient {
private val TAG = "MibFinancingClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchFinancing(session: MibSession): List<MibFinanceDeal> {
val cookieHeader = "mbmodel=IOS-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
"time-tracker=597"
val request = Request.Builder()
.url("$BASE_WV_URL/financing?dashurl=1")
.header("Cookie", cookieHeader)
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "mv.com.mib.faisamobilex")
.get()
.build()
val html = client.newCall(request).execute().use { response ->
Log.d(TAG, "fetchFinancing: HTTP ${response.code}")
if (!response.isSuccessful) return emptyList()
response.body?.string() ?: return emptyList()
}
return parseFinancingHtml(html)
}
private fun parseFinancingHtml(html: String): List<MibFinanceDeal> {
val cardPattern = Regex("""finance-card-holder[^>]+>""")
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
return cardPattern.findAll(html).mapNotNull { cardMatch ->
val attrs = attrPattern.findAll(cardMatch.value)
.associate { it.groupValues[1] to it.groupValues[2] }
val dealNo = attrs["dealNo"] ?: return@mapNotNull null
MibFinanceDeal(
dealNo = dealNo,
productDesc = attrs["productDesc"] ?: "",
dealStatus = attrs["dealStatus"] ?: "",
statusDesc = attrs["statusDesc"] ?: "",
dealAmount = attrs["dealAmount"]?.toDoubleOrNull() ?: 0.0,
paidAmount = attrs["paidAmount"]?.toDoubleOrNull() ?: 0.0,
outstandingAmount = attrs["outstandingAmount"]?.toDoubleOrNull() ?: 0.0,
dealDate = attrs["dealDate"] ?: "",
overdueAmount = attrs["overdueAmount"]?.toDoubleOrNull() ?: 0.0,
installmentAmount = attrs["installmentAmount"]?.toDoubleOrNull() ?: 0.0,
noOfInstallments = attrs["noOfInstallments"]?.toIntOrNull() ?: 0,
lastPaidDate = attrs["lastPaidDate"] ?: "",
lastPayAmount = attrs["lastPayAmount"]?.toDoubleOrNull() ?: 0.0,
currency = attrs["curCodeDesc"] ?: "MVR"
)
}.toList().also { Log.d(TAG, "parsed ${it.size} financing deals") }
}
companion object {
/** Estimate remaining months until financing is fully paid. */
fun remainingMonths(deal: MibFinanceDeal): Int {
if (deal.installmentAmount <= 0.0) return 0
return ceil(deal.outstandingAmount / deal.installmentAmount).toInt()
}
}
}
@@ -15,6 +15,14 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
private val TAG = "MibLoginFlow" private val TAG = "MibLoginFlow"
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/" private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
/** The active session after a successful login, usable for subsequent WebView requests. */
var lastSession: MibSession? = null
private set
/** Profiles returned by the last successful login. */
var lastProfiles: List<MibProfile> = emptyList()
private set
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
@@ -140,6 +148,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
Log.d(TAG, "[login] parsed ${profiles.size} profiles") Log.d(TAG, "[login] parsed ${profiles.size} profiles")
lastSession = session2
lastProfiles = profiles
Log.d(TAG, "[login] step 7: fetch all profiles") Log.d(TAG, "[login] step 7: fetch all profiles")
return fetchAllProfiles(session2, profiles) return fetchAllProfiles(session2, profiles)
} }
@@ -213,6 +223,19 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
put("xxid", session.xxid) put("xxid", session.xxid)
} }
/**
* Activates [profile] server-side via P47, setting the session role context so that
* subsequent WebView requests run under that profile.
*/
fun switchProfile(session: MibSession, profile: MibProfile) {
Log.d(TAG, "switchProfile: profileId=${profile.profileId} cifType=${profile.cifType}")
val payload = baseData(session, "P47").apply {
put("profileType", profile.profileType)
put("profileId", profile.profileId)
}
doRequest(session, payload, "n")
}
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>): List<MibAccount> { private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>): List<MibAccount> {
val allAccounts = mutableListOf<MibAccount>() val allAccounts = mutableListOf<MibAccount>()
for (profile in profiles) { for (profile in profiles) {
@@ -31,3 +31,20 @@ data class MibAccount(
val mvrBalance: String, val mvrBalance: String,
val statusDesc: String val statusDesc: String
) )
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,
val dealStatus: String,
val statusDesc: String,
val dealAmount: Double,
val paidAmount: Double,
val outstandingAmount: Double,
val dealDate: String,
val overdueAmount: Double,
val installmentAmount: Double,
val noOfInstallments: Int,
val lastPaidDate: String,
val lastPayAmount: Double,
val currency: String
)
@@ -0,0 +1,111 @@
package sh.sar.basedbank.ui.home
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun updateDeals(newDeals: List<MibFinanceDeal>) {
deals = newDeals
expandedPositions.clear()
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(deals[position], position in expandedPositions)
holder.binding.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
notifyItemChanged(pos)
}
}
override fun getItemCount() = deals.size
inner class ViewHolder(val binding: ItemFinanceDealBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
val ctx = binding.root.context
val currency = deal.currency
binding.tvProductName.text = deal.productDesc
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
binding.tvStatus.text = deal.statusDesc
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0
binding.progressBar.progress = progress
// Completion estimate
binding.tvCompletion.text = completionText(deal, ctx)
// Expanded details
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.dividerDetails.visibility = detailsVisible
binding.detailsGroup.visibility = detailsVisible
if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate)
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) {
binding.rowOverdue.visibility = View.VISIBLE
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
} else {
binding.rowOverdue.visibility = View.GONE
}
}
}
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
val remaining = MibFinancingClient.remainingMonths(deal)
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, remaining)
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatDate(raw: String): String {
return try {
outputDateFmt.format(inputDateFmt.parse(raw)!!)
} catch (_: Exception) {
raw.take(10)
}
}
}
}
@@ -0,0 +1,47 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentFinancingBinding
class FinancingFragment : Fragment() {
private var _binding: FragmentFinancingBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: FinancingAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = FinancingAdapter(emptyList())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals)
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_finances)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -17,8 +17,13 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding import sh.sar.basedbank.databinding.ActivityHomeBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
class HomeActivity : AppCompatActivity() { class HomeActivity : AppCompatActivity() {
@@ -44,6 +49,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> show(DashboardFragment()) R.id.nav_dashboard -> show(DashboardFragment())
R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java)) R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java))
R.id.nav_accounts -> show(AccountsFragment()) R.id.nav_accounts -> show(AccountsFragment())
R.id.nav_finances -> show(FinancingFragment())
R.id.nav_settings -> show(SettingsFragment()) R.id.nav_settings -> show(SettingsFragment())
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show() else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
} }
@@ -53,13 +59,18 @@ class HomeActivity : AppCompatActivity() {
// Load data // Load data
val app = application as BasedBankApp val app = application as BasedBankApp
if (app.accounts.isNotEmpty()) { if (app.accounts.isNotEmpty()) {
// Came from fresh manual login // Came from fresh manual login — accounts ready, financing fetched in background
viewModel.accounts.value = app.accounts viewModel.accounts.value = app.accounts
AccountCache.save(this, app.accounts) AccountCache.save(this, app.accounts)
val cached = FinancingCache.load(this)
if (cached.isNotEmpty()) viewModel.financing.value = cached
refreshFinancing(app.mibSession, app.mibProfiles)
} else { } else {
// Came from lock screen — show cache immediately, refresh in background // Came from lock screen — show caches immediately, refresh everything in background
val cached = AccountCache.load(this) val cached = AccountCache.load(this)
if (cached.isNotEmpty()) viewModel.accounts.value = cached if (cached.isNotEmpty()) viewModel.accounts.value = cached
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val creds = CredentialStore(this).loadMibCredentials() val creds = CredentialStore(this).loadMibCredentials()
if (creds != null) autoRefresh(creds) if (creds != null) autoRefresh(creds)
} }
@@ -86,7 +97,10 @@ class HomeActivity : AppCompatActivity() {
val accounts = withContext(Dispatchers.IO) { val accounts = withContext(Dispatchers.IO) {
flow.login(creds.username, creds.passwordHash, creds.otpSeed) flow.login(creds.username, creds.passwordHash, creds.otpSeed)
} }
(application as BasedBankApp).accounts = accounts val app = application as BasedBankApp
app.accounts = accounts
app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles
AccountCache.save(this@HomeActivity, accounts) AccountCache.save(this@HomeActivity, accounts)
viewModel.accounts.postValue(accounts) viewModel.accounts.postValue(accounts)
} catch (_: Exception) { } catch (_: Exception) {
@@ -94,6 +108,36 @@ class HomeActivity : AppCompatActivity() {
} finally { } finally {
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
} }
val app = application as BasedBankApp
refreshFinancing(app.mibSession, app.mibProfiles)
}
}
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val client = MibFinancingClient()
lifecycleScope.launch {
try {
val deals = withContext(Dispatchers.IO) {
val allDeals = mutableListOf<MibFinanceDeal>()
val seen = mutableSetOf<String>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (deal in client.fetchFinancing(session)) {
if (seen.add(deal.dealNo)) allDeals.add(deal)
}
} catch (_: Exception) { /* profile has no financing access */ }
}
allDeals
}
if (deals.isNotEmpty()) {
FinancingCache.save(this@HomeActivity, deals)
viewModel.financing.postValue(deals)
}
} catch (_: Exception) { /* keep cached data */ }
} }
} }
} }
@@ -3,7 +3,9 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<MibAccount>>(emptyList()) val accounts = MutableLiveData<List<MibAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
} }
@@ -113,7 +113,10 @@ class CredentialsFragment : Fragment() {
Log.d(TAG, "Login succeeded, got ${accounts.size} accounts") Log.d(TAG, "Login succeeded, got ${accounts.size} accounts")
CredentialStore(requireContext()).saveMibCredentials(username, passwordHash, otpSeed) CredentialStore(requireContext()).saveMibCredentials(username, passwordHash, otpSeed)
AccountCache.save(requireContext(), accounts) AccountCache.save(requireContext(), accounts)
(requireActivity().application as BasedBankApp).accounts = accounts val app = requireActivity().application as BasedBankApp
app.accounts = accounts
app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -0,0 +1,65 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibFinanceDeal
object FinancingCache {
private const val PREFS = "financing_cache"
private const val KEY_MIB = "mib_financing"
fun save(context: Context, deals: List<MibFinanceDeal>) {
val arr = JSONArray()
for (d in deals) {
arr.put(JSONObject().apply {
put("dealNo", d.dealNo)
put("productDesc", d.productDesc)
put("dealStatus", d.dealStatus)
put("statusDesc", d.statusDesc)
put("dealAmount", d.dealAmount)
put("paidAmount", d.paidAmount)
put("outstandingAmount", d.outstandingAmount)
put("dealDate", d.dealDate)
put("overdueAmount", d.overdueAmount)
put("installmentAmount", d.installmentAmount)
put("noOfInstallments", d.noOfInstallments)
put("lastPaidDate", d.lastPaidDate)
put("lastPayAmount", d.lastPayAmount)
put("currency", d.currency)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB, arr.toString()).apply()
}
fun load(context: Context): List<MibFinanceDeal> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibFinanceDeal(
dealNo = o.optString("dealNo"),
productDesc = o.optString("productDesc"),
dealStatus = o.optString("dealStatus"),
statusDesc = o.optString("statusDesc"),
dealAmount = o.optDouble("dealAmount", 0.0),
paidAmount = o.optDouble("paidAmount", 0.0),
outstandingAmount = o.optDouble("outstandingAmount", 0.0),
dealDate = o.optString("dealDate"),
overdueAmount = o.optDouble("overdueAmount", 0.0),
installmentAmount = o.optDouble("installmentAmount", 0.0),
noOfInstallments = o.optInt("noOfInstallments", 0),
lastPaidDate = o.optString("lastPaidDate"),
lastPayAmount = o.optDouble("lastPayAmount", 0.0),
currency = o.optString("currency", "MVR")
)
}
} catch (e: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/loadingView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/financing_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,328 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Header row: product name + status chip -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvProductName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvDealNo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
</LinearLayout>
<!-- Total amount -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_total"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvTotal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- Progress bar -->
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- Paid / Unpaid row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_paid"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvPaid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="end">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_unpaid"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvUnpaid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/color_unpaid"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Completion estimate -->
<TextView
android:id="@+id/tvCompletion"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Divider -->
<View
android:id="@+id/dividerDetails"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone" />
<!-- Expanded details -->
<LinearLayout
android:id="@+id/detailsGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<!-- Deal date -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/financing_deal_date"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvDealDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Installment amount -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/financing_installment"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvInstallment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Number of installments -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/financing_num_installments"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvNumInstallments"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Last paid date -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/financing_last_paid_date"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLastPaidDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Last payment amount -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/financing_last_pay_amount"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLastPayAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Overdue amount (only shown when > 0) -->
<LinearLayout
android:id="@+id/rowOverdue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/financing_overdue"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@color/color_unpaid" />
<TextView
android:id="@+id/tvOverdue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@color/color_unpaid" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+1
View File
@@ -3,4 +3,5 @@
<!-- Seed colors from MIB brand (blue + green) — used as M3 fallback on Android <12 --> <!-- Seed colors from MIB brand (blue + green) — used as M3 fallback on Android <12 -->
<color name="seed_primary">#3F65AD</color> <color name="seed_primary">#3F65AD</color>
<color name="seed_secondary">#9AD141</color> <color name="seed_secondary">#9AD141</color>
<color name="color_unpaid">#E85D04</color>
</resources> </resources>
+15
View File
@@ -92,4 +92,19 @@
<!-- Home --> <!-- Home -->
<string name="accounts">Accounts</string> <string name="accounts">Accounts</string>
<string name="available_balance">Available Balance</string> <string name="available_balance">Available Balance</string>
<!-- Financing -->
<string name="financing_empty">No financing deals found</string>
<string name="financing_total">Total</string>
<string name="financing_paid">Paid</string>
<string name="financing_unpaid">Unpaid</string>
<string name="financing_deal_date">Deal Date</string>
<string name="financing_installment">Monthly Installment</string>
<string name="financing_num_installments">Total Installments</string>
<string name="financing_last_paid_date">Last Payment Date</string>
<string name="financing_last_pay_amount">Last Payment</string>
<string name="financing_overdue">Overdue</string>
<string name="financing_completion_done">Fully paid</string>
<string name="financing_deal_no_fmt">Deal #%s</string>
<string name="financing_completion_fmt">Completes %s</string>
</resources> </resources>
+109
View File
@@ -0,0 +1,109 @@
# MIB Financing API
## Overview
Financing data is fetched from the MIB **WebView** host (`faisamobilex-wv.mib.com.mv`), which is separate from the API host (`faisanet.mib.com.mv`). The response is an HTML page; financing deal data is embedded in `data-*` attributes on card elements.
---
## Endpoint
```
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
```
### Authentication
Session cookies from the login flow must be sent with the request:
| Cookie | Value |
|----------------|---------------------------------------------|
| `mbmodel` | `IOS-1.0` (literal string) |
| `xxid` | Session ID from login (`MibSession.xxid`) |
| `IBSID` | Same as `xxid` |
| `mbnonce` | `nonceGenerator` string from login response |
| `time-tracker` | `597` (literal string) |
### Request Headers
| Header | Value |
|--------------------|------------------------------------|
| `User-Agent` | Standard Android WebView UA string |
| `X-Requested-With` | `mv.com.mib.faisamobilex` |
---
## Response
**Content-Type:** `text/html; charset=UTF-8`
The response is a full HTML page. Each financing deal is represented as a `<div>` with the class `finance-card-holder` and all deal fields embedded as `data-*` attributes:
```html
<div class="card border finance-card-holder"
data-productDesc = "Product Name"
data-dealStatus = "P"
data-statusDesc = "Approved"
data-dealAmount = "10000.00"
data-dealNo = "12345"
data-paidAmount = "2500.00"
data-outstandingAmount = "7500.00"
data-dealDate = "2024-01-15 00:00:00"
data-overdueAmount = "0"
data-installmentAmount = "500.00"
data-noOfInstallments = "24"
data-lastPaidDate = "2026-05-01 00:00:00"
data-lastPayAmount = "500.00"
data-financeCurrency = "462"
data-curCodeDesc = "MVR">
```
### Data Fields
| Field | Type | Description |
|-----------------------|---------|------------------------------------------------------|
| `productDesc` | String | Product name (e.g. "Ujalaa CG Finance") |
| `dealStatus` | String | Status code: `P` = Active/Pending |
| `statusDesc` | String | Human-readable status (e.g. "Approved") |
| `dealAmount` | Decimal | Total financing amount |
| `dealNo` | Integer | Unique deal/contract number |
| `paidAmount` | Decimal | Amount paid to date |
| `outstandingAmount` | Decimal | Remaining unpaid balance |
| `dealDate` | String | Contract start date (`yyyy-MM-dd HH:mm:ss`) |
| `overdueAmount` | Decimal | Amount currently overdue (0 if none) |
| `installmentAmount` | Decimal | Monthly installment amount |
| `noOfInstallments` | Integer | Total number of installments |
| `lastPaidDate` | String | Date of most recent payment (`yyyy-MM-dd HH:mm:ss`) |
| `lastPayAmount` | Decimal | Amount of most recent payment |
| `financeCurrency` | Integer | Currency code (462 = MVR) |
| `curCodeDesc` | String | Currency abbreviation (e.g. "MVR") |
### Parsing Strategy
Use a regex to find all elements with class `finance-card-holder`, then extract all `data-*` attribute key/value pairs from each match:
```kotlin
val cardPattern = Regex("""finance-card-holder[^>]+>""")
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
```
---
## Completion Date Estimation
Remaining installments can be estimated from outstanding and installment amounts:
```
remainingInstallments = ceil(outstandingAmount / installmentAmount)
completionDate = today + remainingInstallments months
```
---
## Notes
- The WebView endpoint uses a different subdomain (`faisamobilex-wv`) from the encrypted API (`faisanet`).
- No encryption is used; the session is maintained purely via cookies.
- The HTML is served gzip/brotli compressed; OkHttp handles decompression automatically.
- The `time-tracker` cookie value appears to be static at `597` — its purpose is unclear, but omitting it may affect behavior.
- Known product names include consumer goods finance and cash financing variants.