implement viewing contacts
This commit is contained in:
155
app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt
Normal file
155
app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
110
app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt
Normal file
110
app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
123
app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt
Normal file
123
app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
97
app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt
Normal file
97
app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/src/main/res/layout/fragment_contacts.xml
Normal file
71
app/src/main/res/layout/fragment_contacts.xml
Normal 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>
|
||||
63
app/src/main/res/layout/item_contact.xml
Normal file
63
app/src/main/res/layout/item_contact.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
249
docs/mibapi/contacts.md
Normal 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`.
|
||||
Reference in New Issue
Block a user