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()
}
}
}