implement viewing contacts

This commit is contained in:
2026-05-13 00:09:52 +05:00
parent e875163487
commit de29dc627f
12 changed files with 950 additions and 1 deletions

View File

@@ -0,0 +1,155 @@
package sh.sar.basedbank.api.mib
import android.util.Log
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibContactsClient {
private val TAG = "MibContactsClient"
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()
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 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/beneficiary?dashurl=1")
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> {
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getCategories")
.post(FormBody.Builder().build())
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "getCategories: HTTP ${response.code}")
if (!response.isSuccessful) return emptyList()
parseCategories(response.body?.string() ?: return emptyList())
}
}
private fun parseCategories(json: String): List<MibBeneficiaryCategory> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
id = o.getString("id"),
categoryName = o.getString("categoryName"),
numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 0
)
}
} catch (e: Exception) {
Log.e(TAG, "parseCategories error: $e")
emptyList()
}
}
fun fetchContacts(session: MibSession): List<MibBeneficiary> {
val all = mutableListOf<MibBeneficiary>()
var page = 1
val pageSize = 100
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 ->
Log.d(TAG, "fetchContacts page $page: HTTP ${response.code}")
if (!response.isSuccessful) return all
parseContacts(response.body?.string() ?: return all)
}
all.addAll(contacts)
if (all.size >= totalCount || contacts.isEmpty()) break
page++
}
Log.d(TAG, "fetchContacts: loaded ${all.size} contacts")
return all
}
private fun parseContacts(json: String): Pair<List<MibBeneficiary>, Int> {
return try {
val obj = JSONObject(json)
val totalCount = obj.optString("total_count", "0").toIntOrNull() ?: 0
val arr = obj.getJSONArray("data")
val contacts = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
val hash = o.optString("customerImgHash")
MibBeneficiary(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName")
.ifBlank { o.optString("benefName") },
benefAccount = o.optString("benefAccount"),
benefType = o.optString("benefType"),
bankColor = o.optString("bankColor", "#888888"),
benefBankName = o.optString("benefBankName"),
bankCode = o.optString("bankCode"),
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = hash.takeIf { it.isNotBlank() && it != "null" },
benefCategoryId = o.optString("benefCategoryID", "0")
)
}
Pair(contacts, totalCount)
} catch (e: Exception) {
Log.e(TAG, "parseContacts error: $e")
Pair(emptyList(), 0)
}
}
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
}
}
}
}

View File

@@ -32,6 +32,27 @@ data class MibAccount(
val statusDesc: String
)
data class MibBeneficiaryCategory(
val id: String,
val categoryName: String,
val numBenef: Int
)
data class MibBeneficiary(
val benefNo: String,
val benefName: String,
val benefNickName: String,
val benefAccount: String,
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
val bankColor: String,
val benefBankName: String,
val bankCode: String,
val benefStatus: String,
val transferCyDesc: String,
val customerImgHash: String?,
val benefCategoryId: String // "0" = uncategorized
)
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,

View File

@@ -0,0 +1,110 @@
package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding
class ContactsAdapter(
private val onImageNeeded: (hash: String) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
private var activeCategoryId: String? = null
private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) {
allContacts = contacts
applyFilter()
}
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index)
}
}
fun setFilter(categoryId: String?, query: String) {
activeCategoryId = categoryId
searchQuery = query
applyFilter()
}
private fun applyFilter() {
displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery)
matchesCategory && matchesSearch
}
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
}
override fun getItemCount() = displayed.size
inner class ViewHolder(private val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
binding.tvContactName.text = contact.benefNickName
binding.tvContactBank.text = contact.benefBankName
binding.tvContactAccount.text = "${contact.benefAccount} · ${contact.transferCyDesc}"
if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo)
} else {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
)
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96)
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
val textY = sizePx / 2f - (metrics.ascent + metrics.descent) / 2f
canvas.drawText(letter, sizePx / 2f, textY, paint)
return bm
}
}
}

View File

@@ -0,0 +1,123 @@
package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding
class ContactsFragment : Fragment() {
private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: ContactsAdapter
private val pendingHashes = mutableSetOf<String>()
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private var categories: List<MibBeneficiaryCategory> = emptyList()
private var activeCategoryId: String? = null // null = All
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ContactsAdapter { hash -> fetchImage(hash) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.etSearch.addTextChangedListener { text ->
adapter.setFilter(activeCategoryId, text?.toString() ?: "")
}
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
activeCategoryId = tab.tag as? String
adapter.setFilter(activeCategoryId, binding.etSearch.text?.toString() ?: "")
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
categories = cats
rebuildTabs(cats)
}
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
adapter.updateContacts(contacts)
binding.recyclerView.visibility = if (contacts.isEmpty()) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
}
private fun rebuildTabs(cats: List<MibBeneficiaryCategory>) {
binding.tabLayout.clearOnTabSelectedListeners()
binding.tabLayout.removeAllTabs()
binding.tabLayout.addTab(
binding.tabLayout.newTab().setText(R.string.contacts_tab_all).apply { tag = null }
)
for (cat in cats) {
binding.tabLayout.addTab(
binding.tabLayout.newTab().setText(cat.categoryName).apply { tag = cat.id }
)
}
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
activeCategoryId = tab.tag as? String
adapter.setFilter(activeCategoryId, binding.etSearch.text?.toString() ?: "")
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
}
private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return
val sess = session ?: return
val client = MibContactsClient()
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) {
adapter.updateImage(hash, bitmap)
}
} catch (_: Exception) {
pendingHashes.remove(hash) // allow retry
}
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_contacts)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -17,11 +17,15 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
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.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
@@ -49,6 +53,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> show(DashboardFragment())
R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java))
R.id.nav_accounts -> show(AccountsFragment())
R.id.nav_contacts -> show(ContactsFragment())
R.id.nav_finances -> show(FinancingFragment())
R.id.nav_settings -> show(SettingsFragment())
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
@@ -59,18 +64,27 @@ class HomeActivity : AppCompatActivity() {
// Load data
val app = application as BasedBankApp
if (app.accounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, financing fetched in background
// Came from fresh manual login — accounts ready, rest fetched in background
viewModel.accounts.value = app.accounts
AccountCache.save(this, app.accounts)
val cached = FinancingCache.load(this)
if (cached.isNotEmpty()) viewModel.financing.value = cached
val cachedContacts = ContactsCache.loadContacts(this)
if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
val cachedCats = ContactsCache.loadCategories(this)
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats
refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles)
} else {
// Came from lock screen — show caches immediately, refresh everything in background
val cached = AccountCache.load(this)
if (cached.isNotEmpty()) viewModel.accounts.value = cached
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedContacts = ContactsCache.loadContacts(this)
if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
val cachedCats = ContactsCache.loadCategories(this)
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats
val creds = CredentialStore(this).loadMibCredentials()
if (creds != null) autoRefresh(creds)
}
@@ -117,6 +131,41 @@ class HomeActivity : AppCompatActivity() {
}
val app = application as BasedBankApp
refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles)
}
}
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val contactsClient = MibContactsClient()
lifecycleScope.launch {
try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
val seenCategories = mutableSetOf<String>()
val contacts = mutableListOf<MibBeneficiary>()
val categories = mutableListOf<MibBeneficiaryCategory>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
if (seenContacts.add(contact.benefNo)) contacts.add(contact)
}
} catch (_: Exception) { /* profile has no contacts access */ }
}
Pair(contacts, categories)
}
if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
viewModel.contacts.postValue(allContacts)
viewModel.contactCategories.postValue(allCategories)
}
} catch (_: Exception) { /* keep cached data */ }
}
}

View File

@@ -3,9 +3,13 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<MibAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList())
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList())
}

View File

@@ -0,0 +1,97 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
object ContactsCache {
private const val PREFS = "contacts_cache"
private const val KEY_CONTACTS = "mib_contacts"
private const val KEY_CATEGORIES = "mib_categories"
fun save(
context: Context,
contacts: List<MibBeneficiary>,
categories: List<MibBeneficiaryCategory>
) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
val contactsArr = JSONArray()
for (c in contacts) {
contactsArr.put(JSONObject().apply {
put("benefNo", c.benefNo)
put("benefName", c.benefName)
put("benefNickName", c.benefNickName)
put("benefAccount", c.benefAccount)
put("benefType", c.benefType)
put("bankColor", c.bankColor)
put("benefBankName", c.benefBankName)
put("bankCode", c.bankCode)
put("benefStatus", c.benefStatus)
put("transferCyDesc", c.transferCyDesc)
put("customerImgHash", c.customerImgHash ?: "")
put("benefCategoryId", c.benefCategoryId)
})
}
prefs.putString(KEY_CONTACTS, contactsArr.toString())
val catArr = JSONArray()
for (cat in categories) {
catArr.put(JSONObject().apply {
put("id", cat.id)
put("categoryName", cat.categoryName)
put("numBenef", cat.numBenef)
})
}
prefs.putString(KEY_CATEGORIES, catArr.toString())
prefs.apply()
}
fun loadContacts(context: Context): List<MibBeneficiary> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CONTACTS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiary(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName"),
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 = o.optString("customerImgHash").takeIf { it.isNotBlank() },
benefCategoryId = o.optString("benefCategoryId", "0")
)
}
} catch (e: Exception) {
emptyList()
}
}
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
id = o.optString("id"),
categoryName = o.optString("categoryName"),
numBenef = o.optInt("numBenef", 0)
)
}
} catch (e: Exception) {
emptyList()
}
}
}

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/contacts_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingVertical="4dp" />
<ProgressBar
android:id="@+id/loadingView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="visible" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/contacts_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivContactPhoto"
android:layout_width="48dp"
android:layout_height="48dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/tvContactName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/ivContactPhoto" />
<TextView
android:id="@+id/tvContactBank"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvContactName" />
<TextView
android:id="@+id/tvContactAccount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="1dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvContactBank"
app:layout_constraintBottom_toBottomOf="@id/ivContactPhoto" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -99,6 +99,11 @@
<string name="transfer_amount">Amount</string>
<string name="transfer_remarks">Remarks</string>
<!-- Contacts -->
<string name="contacts_empty">No contacts found</string>
<string name="contacts_search_hint">Search contacts</string>
<string name="contacts_tab_all">All</string>
<!-- Financing -->
<string name="financing_empty">No financing deals found</string>
<string name="financing_total">Total</string>

View File

@@ -1,4 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/seed_primary</item>
<item name="colorSecondary">@color/seed_secondary</item>

249
docs/mibapi/contacts.md Normal file
View File

@@ -0,0 +1,249 @@
# MIB Contacts (Beneficiary) API
The contacts/beneficiary system is served from the MIB WebView subdomain. All endpoints use
session-cookie authentication (same cookies as the financing WebView).
## Authentication
All requests use the same session cookies:
```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
```
All AJAX POST requests also require:
```
X-Requested-With: XMLHttpRequest
Origin: https://faisamobilex-wv.mib.com.mv
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
```
---
## Endpoints
### 1. Get Categories
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories`
No request body required (empty POST).
**Response:**
```json
{
"success": true,
"responseCode": "1",
"reasonText": "Category retrieval success",
"reasonCode": "105",
"data": [
{
"id": "100001",
"categoryName": "Myself",
"icon": "f091",
"createdDate": "2023-01-01 00:00:00",
"modifiedDate": null,
"numBenef": "2"
},
{
"id": "100002",
"categoryName": "Friends",
"icon": "f095",
"createdDate": "2023-01-01 00:00:00",
"modifiedDate": "2023-01-02 00:00:00",
"numBenef": "10"
},
{
"id": "100003",
"categoryName": "Business",
"icon": "f097",
"createdDate": "2023-01-01 00:00:00",
"modifiedDate": "2023-01-02 00:00:00",
"numBenef": "8"
},
{
"id": "100004",
"categoryName": "Family",
"icon": "f090",
"createdDate": "2023-01-01 00:00:00",
"modifiedDate": "2023-01-02 00:00:00",
"numBenef": "5"
}
]
}
```
Fields:
- `id` — category ID (used as `searchCategoryId` when filtering contacts)
- `categoryName` — display name
- `icon` — font-awesome icon code (used in web UI, ignore in native app)
- `numBenef` — number of beneficiaries in this category (string)
---
### 2. Get Contacts (Paginated)
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main`
**Request body (form-urlencoded):**
| Field | Example | Description |
|--------------------|----------|-----------------------------------------------------------|
| `page` | `1` | Page number (1-based) |
| `search` | `` | Search query (empty = all) |
| `searchCategoryId` | `0` | Category filter (`0` = all categories) |
| `benefType` | `A` | Beneficiary type: `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
| `sortBenef` | `name` | Sort field |
| `sortDir` | `asc` | Sort direction |
| `start` | `1` | Record start index (1-based) |
| `end` | `100` | Record end index |
| `includeCount` | `1` | Include `total_count` in response |
**Beneficiary types:**
- `L` — Local (other Maldivian banks, e.g. BML)
- `I` — Internal (MIB to MIB transfers)
- `S` — Swift (international transfers)
**Response:**
```json
{
"success": true,
"responseCode": "1",
"reasonText": "beneficiary retrieval success",
"reasonCode": "103",
"data": [
{
"benefNo": "100001",
"benefName": "Person Name",
"benefNickName": "Nickname",
"benefAccount": "7700000000001",
"benefType": "L",
"bankColor": "#AC0000",
"benefBankName": "Bank of Maldives PLC",
"bankCode": "BML",
"benefBankBranch": null,
"benefAddress1": null,
"benefAddress2": null,
"benefAddress3": null,
"benefCity": null,
"benefRegion": null,
"benefCountry": null,
"benefStatus": "A",
"benefBankId": "3",
"benefSwiftCode": "MALBMVMV",
"transferCy": "462",
"transferCyDesc": "MVR",
"bicCode": null,
"intermBankCode": "0",
"customerImgHash": "abcd1234hash...",
"benefImgHash": "abcd1234hash...",
"benefCategoryID": "100002",
"BENEF_CIF_NO": null,
"rnum": "1",
"last": "0"
},
{
"benefNo": "100002",
"benefName": "Another Person",
"benefNickName": "MIB Contact",
"benefAccount": "90103100000001000",
"benefType": "I",
"bankColor": "#FE860E",
"benefBankName": "MIB",
"bankCode": "MIB",
"benefBankBranch": null,
"benefStatus": "A",
"benefBankId": "2",
"benefSwiftCode": "SWIFTCODE",
"transferCy": "462",
"transferCyDesc": "MVR",
"customerImgHash": null,
"benefImgHash": null,
"benefCategoryID": "0",
"rnum": "2",
"last": "1"
}
],
"total_count": "48",
"pos": "1"
}
```
Key fields:
- `benefNo` — unique beneficiary ID
- `benefNickName` — user-assigned nickname (prefer over `benefName` for display)
- `benefType` — `L`, `I`, or `S`
- `bankColor` — hex color representing the bank (use for placeholder avatar background)
- `customerImgHash` — hash used to fetch profile photo (null if no photo set)
- `benefCategoryID` — category ID, `"0"` means uncategorized
- `transferCyDesc` — currency (MVR, USD)
- `rnum` — row number (1-based position in full sorted list)
- `last` — `"1"` if this is the last record on the page
Pagination: use `start`/`end` to page through results. `total_count` gives the total number of records.
---
### 3. Get Profile Image
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage`
**Request body (form-urlencoded):**
| Field | Description |
|-------------|------------------------------------|
| `imageHash` | The `customerImgHash` from contact |
**Response:**
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "1",
"reasonText": "image found",
"profileImage": "<base64-encoded JPEG>"
}
```
- `profileImage` — raw base64-encoded JPEG (no data URI prefix)
- Decode with `Base64.decode(value, Base64.DEFAULT)` then `BitmapFactory.decodeByteArray(...)`
- The same hash may be reused across multiple contacts (deduplication recommended)
---
### 4. Get Stats (optional)
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats`
No request body required.
**Response:**
```json
{
"success": true,
"responseCode": "1",
"reasonText": "Beneficiary Stats Retrieved",
"reasonCode": "109",
"data": [
{ "type": "L", "count": "30" },
{ "type": "I", "count": "10" },
{ "type": "S", "count": "2" }
]
}
```
Gives counts per beneficiary type. Useful for showing tab badges.
---
## Notes
- Profile images are fetched on-demand per contact. Cache decoded bitmaps in memory to avoid re-fetching.
- Contacts with `customerImgHash == null` have no profile photo; show initials + bank color as placeholder.
- The `benefCategoryID` of `"0"` means uncategorized (not in any category group).
- Pagination: use `start=1&end=100` for the first 100 records. Increment accordingly using `total_count`.